---
title: "A quick overview for up to 10 people in one call"
method: POST
path: "/v1/research/person/batch"
tags: ["Research"]
---

# A quick overview for up to 10 people in one call

`POST /v1/research/person/batch`

## Request

- Base URL: `https://api.trayo.ai`
- URL: `https://api.trayo.ai/v1/research/person/batch`
- Auth: HTTP bearer

**Required permissions:** `research:trigger`.

Returns every overview in the same response. One person typically takes 2–3 seconds, and people are synthesized several at a time, so a full 10-person call takes a small multiple of that rather than 10 times it. The server stops after 120 seconds by default with `503 research_failed`. Set your client timeout above 120 seconds and retry once on `retry_later`. Each call generates a new answer. It saves nothing to your workspace. The company or person does not need to be in your workspace. This is the same quick overview the Trayo app shows. Public REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. 

Same as `POST /v1/research/person`, once per person in `people` (1 to 10), reading the workspace seller context once for the whole batch. Always ungrounded — `grounded` is not accepted here. A person that cannot be resolved, or whose overview comes back empty (`person_profile_too_thin`), lands in `errors` by `index` with a `code` and a `retry` class, and never fails the rest of the batch. Only the overviews in `results` draw on the quota — a person in `errors` does not.

## Request body

- PersonBatchResearchBodyDto
  - `people` object[], required: 1 to 10 people, each like the single `person` field of POST /v1/research/person. Over the cap is 400 validation_failed, never a clamp. Always ungrounded.
    - `personId` string, uuid: A person in your workspace, on its own. Their existing match is read when they have one; otherwise their LinkedIn username is looked up, so a person you have only just imported still works when the profile is in our data.
    - `linkedinUrl` string: The person's LinkedIn profile — the `linkedinUrl` a find result or a stakeholder-search result carries, copied through (`https://www.linkedin.com/in/jane-doe`); the bare handle (`jane-doe`) is accepted too. Does not have to be in your workspace.

## Response `200`

One outcome per submitted person, in `results` or `errors`.

- PersonBatchResearchResponseDto
  - `results` object[], required: One overview per resolved person. A mixed batch never fails as a whole.
    - `person` object, required
      - `fullName` string, required: The person as Trayo records them.
      - `linkedinUrl` string, nullable, required: Their LinkedIn profile, when Trayo has it.
      - `photoUrl` string, nullable, required: The person's profile photo, as a directly loadable image URL. Null when Trayo holds no real photo for them — show initials then. Every route that returns a person carries this field under this name.
      - `location` object, nullable, required: Where the person says they are on their LinkedIn profile. Usually their home metro area, but it is the person's own statement, not a verified address, and it is not where their company is headquartered. Null when Trayo has no location for them. From the profile Trayo holds, in every mode. Not the same field as `research.location`, which is a web fact stated only when `grounded` is on and Trayo could cite it.
        - `city` string, nullable, required: City, e.g. `San Diego`. Null when the profile names only a region or metro area.
        - `state` string, nullable, required: State, province or region, e.g. `California`.
        - `country` string, nullable, required: ISO 3166-1 alpha-2 country code, e.g. `US`.
        - `raw` string, nullable, required: The location as written on the profile, e.g. `Greater San Diego Area`. Usually present; the only populated field when the profile names just a metro area.
      - `companyDomain` string, nullable, required: The website domain of the employer named in research.company, for displaying its logo. Null when Trayo has no matching employer domain.
      - `company` object, nullable, required: The employer of the person's current role on the profile Trayo holds. Usually the company `research.company` names, which is free text. Null when the profile shows no current role.
        - `companyId` string, nullable, required: Trayo's id for this employer. Send it back as `company.companyId` (company research, stakeholder search, job-change search) to name this exact company. Null when Trayo holds no company record for the employer: `name` is then the free text from the profile, and looking it up by name will usually miss. Search for it with a find companies `query` instead.
        - `name` string, nullable, required: The employer as the profile names it.
        - `website` string, nullable, required: The employer's website, when Trayo holds a record for it.
        - `linkedinUrl` string, nullable, required: The employer's LinkedIn page, when Trayo holds a record for it.
      - `personId` string, uuid, nullable, required: The person this call was scoped to, when you sent one.
      - `accountId` string, uuid, nullable, required: That person's account in your workspace, when they have one.
      - `resolvedBy` 'stored_match' | 'linkedin', required: `stored_match`: the workspace person's existing match. `linkedin`: the LinkedIn profile, looked up by its handle.
    - `grounded` boolean, required: Whether web-search grounding was used for this overview.
    - `research` object, required: The overview as structured fields. `reportMarkdown` is the same content rendered for display. Neither field carries source links: a grounded fact Trayo could not source is withheld rather than stated, and the link behind one it could source is not returned.
      - `fullName` string, required
      - `title` string, nullable, required
      - `company` string, nullable, required
      - `careerProgression` string[], required: Notable roles, oldest first, each as `Role at Company (start – end)`.
      - `professionalInsights` object[], required: Their role and trajectory, tied to how what your workspace sells could be relevant.
        - `label` string, required: A short categorisation of the point ("Acquisition", "Analyst Recognition", "Enterprise Scale") — what you can group or scan by. Not a restatement of `text`.
        - `text` string, required: The point itself.
      - `challenges` object[], required: What they are likely up against. Inferred from role, seniority and industry when `grounded` is off; evidenced and cited when it is on.
        - `label` string, required: A short categorisation of the point ("Acquisition", "Analyst Recognition", "Enterprise Scale") — what you can group or scan by. Not a restatement of `text`.
        - `text` string, required: The point itself.
      - `personalInsights` object[], required: Only ever filled when `grounded` is on; empty otherwise.
        - `label` string, required: A short categorisation of the point ("Acquisition", "Analyst Recognition", "Enterprise Scale") — what you can group or scan by. Not a restatement of `text`.
        - `text` string, required: The point itself.
      - `companyIntel` object[], required: Timely, cited facts about their company a seller should know now. Only ever filled when `grounded` is on; empty otherwise.
        - `label` string, required: A short categorisation of the point ("Acquisition", "Analyst Recognition", "Enterprise Scale") — what you can group or scan by. Not a restatement of `text`.
        - `text` string, required: The point itself.
      - `location` string, nullable, required: Present only when `grounded` is on AND Trayo could cite a source for it. An uncited location is withheld rather than guessed, so null means "could not be sourced", never "lives nowhere". The source itself is not published.
      - `education` string, nullable, required: Same rule as `location`: sourced or withheld.
    - `reportMarkdown` string, required: The same overview rendered as Markdown, ready to show — headings and bullet points. `research` is the structured form of it. Carries no source links; see the note on `research`.
    - `generatedAt` string, date-time, required: When this overview was generated — always this call; nothing is stored.
    - `index` integer, required: Which entry of the submitted `people` this overview is for, 0-based.
  - `errors` object[], required: One entry per person this call could not produce an overview for.
    - `index` integer, required: Which entry of the submitted `people` this failure is for, 0-based.
    - `code` 'data_search_busy' | 'data_search_timeout' | 'validation_failed' | 'malformed_cursor' | 'invalid_date' | 'unknown_account' | 'unknown_signal' | 'unauthorized' | 'insufficient_scope' | 'forbidden' | 'not_found' | 'account_not_found' | 'signal_not_found' | 'event_not_found' | 'discovery_run_not_found' | 'person_not_found' | 'list_not_found' | 'discovery_not_found' | 'list_member_not_found' | 'method_not_allowed' | 'idempotency_in_progress' | 'conflict' | 'signal_key_conflict' | 'idempotency_key_reused' | 'list_in_use' | 'quota_daily_limit' | 'quota_exceeded' | 'lookup_limit_reached' | 'lookup_start_failed' | 'contact_lookup_not_found' | 'phone_lookup_failed' | 'phone_lookup_expired' | 'contact_lookup_failed' | 'company_not_found' | 'company_resolution_timed_out' | 'too_many_names' | 'job_change_search_not_configured' | 'job_change_search_failed' | 'lookalike_search_not_configured' | 'lookalike_search_failed' | 'competitor_roster_not_configured' | 'competitor_roster_failed' | 'post_search_not_configured' | 'post_search_failed' | 'job_search_not_configured' | 'job_search_failed' | 'stakeholder_search_not_configured' | 'stakeholder_search_failed' | 'stakeholder_search_not_found' | 'person_ask_not_configured' | 'person_ask_failed' | 'person_profile_not_found' | 'person_profile_too_thin' | 'research_not_configured' | 'research_failed' | 'find_needs_indexed_filter' | 'rate_limited' | 'internal_error' | 'find_not_configured' | 'find_response_failed' | 'discovery_run_too_large' | 'mcp_result_delivery_failed' | 'mcp_result_not_found' | 'mcp_result_expired' | 'mcp_tool_removed' | 'model_unavailable' | 'find_unavailable' | 'find_timeout' | 'app_not_found' | 'deployment_not_found' | 'app_slug_unavailable' | 'app_not_ready' | 'app_state_conflict' | 'archive_upload_missing' | 'archive_too_large' | 'archive_rejected' | 'public_mode_not_allowed' | 'hosted_apps_unavailable' | 'public_records_not_configured' | 'public_records_unavailable' | 'public_records_throttled', required: Why this person failed, in the error envelope's `code` vocabulary, e.g. `person_not_found`. Usually the code a single call would return; `person_profile_too_thin` is the batch's own, for an overview that came back empty.
    - `retry` 'fix_input' | 'retry_later' | 'do_not_retry', required: What to do about the failure, and the only field you need in order to decide whether to send the request again. `fix_input` — the request is wrong; change it and retry. `retry_later` — the request is right, the system is not ready; retry it unchanged, with backoff and a FINITE number of attempts. On a 500 (`internal_error`) this especially does not mean retry indefinitely: the failure is not known to be transient, only that nothing about your request needs changing, so give up after a few attempts rather than looping. `do_not_retry` — retrying will never help. Fixed per `code`; see the table above. Always present, on every error, including any code added after this document — so a client that does not recognise a `code` can still act correctly by reading this field alone.
    - `message` string, required
    - `param` string: Names the offending field, e.g. `people[2].personId`.

## Other responses

- `400`: `people` empty, over 10, containing an item with neither or both of `personId` / `linkedinUrl`, or a top-level `grounded` field. `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`: `quota_exceeded` (`retry: do_not_retry`) — This workspace has insufficient quota for this operation. Retrying or changing the request does not help; raise the quota or wait for it to renew.
- `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. `quota_daily_limit` (`retry: retry_later`) — The workspace daily usage limit blocks new work. Wait for recent usage to leave the 24-hour window, or contact support; changing scopes does not help.
- `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`: `research_not_configured` (`retry: do_not_retry`) — Company and person research are unavailable. Retrying will not help. Contact support. `research_failed` (`retry: retry_later`) — A research route failed or ran past the time budget. Nothing was written. Send the same request 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)
