---
title: "Create an account"
method: POST
path: "/v1/accounts"
tags: ["Accounts"]
---

# Create an account

`POST /v1/accounts`

## Request

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

**Required permissions:** `accounts:write`.

An account is a name, an optional URL and an optional LinkedIn handle. That is the whole record — see `AccountDto` for what reads back. `name` is the only required field: an account you have no website for is created with `url: null` rather than refused, the same way `POST /v1/people` treats a `company` with no `domain`. Send `url` whenever you have it — it is this route's deduplication key. Supply `linkedinHandle` whenever you know the company page: use the bare handle (for example `stripe`), not the full LinkedIn URL. It is the strongest way to identify the company and takes precedence over the website and name.

You can start discovery immediately after creating an account. These outcomes apply to every signal type, including `news`. While a run is active, keep polling the same run. `account_not_ready` means Trayo could not search the account this time. After the run settles, retry the affected account IDs. `company_not_found` means Trayo could not identify the company from the supplied details. For `company_not_found`, check the named accounts before starting another discovery. If an identity is wrong, send the correct `linkedinHandle` with `trayo_update_account` over MCP, or with `PATCH /v1/accounts/{id}` and a workspace API key over REST. When no handle is known, send a corrected website `url` instead. Both need `accounts:write`. `trayo_update_account` also reports whether Trayo can now identify the company. Importing the same hostname again skips the existing account and does not update it. After the current run settles, start a new discovery for the corrected account IDs.

Public REST and MCP usage is recorded without daily usage caps, workspace quota cutoffs or lookup quotas for now. Workspace request pacing still applies. **This call adds the account only, never people.** Finding people and adding them are separate steps: search with `POST /v1/find` as many times as you need, then add the people you chose with `POST /v1/people/batch`.

To attach people to future events, first set the workspace stakeholder definition with `PATCH /v1/workspace`, then start discovery with `people: "add"`. This definition does not add people to older events.

**A `POST /v1/find` company row is NOT postable verbatim.** Its `id`, `website`, `linkedinUrl`, `industry`, `headcount`, `hq`, `fundingStage`, `foundedYear`, `companyType`, `description`, `why`, `known`, `state` fields are all refused with `validation_failed`, and the message names the offending keys. They are refused rather than accepted-and-dropped on purpose: these company details cannot be saved through this route. Select the accepted fields first: `jq '.data[] | select(.state == "selectable" and .url) | {name, url}'`.

## Request body

- CreateAccountDto: The created account is automatically assigned to the user your API key acts as, which is what makes it eligible for discovery — see `POST /v1/discoveries`. There is no way to create an unassigned account through this API. **Unknown fields are rejected** (`validation_failed`), not silently dropped — a misspelled field is an error, so you find out. **Supply `linkedinHandle` whenever you know the company page.** Use the bare company handle, not the full LinkedIn URL. It is the strongest way to identify the company and is tried before the website and name. Sending it at create time makes a `company_not_found` result less likely. **`name` is the only required field.** An account you have a name but no website for is created with `url: null` rather than refused, matching `POST /v1/people`'s `company`, and is deduplicated by that name. Send `url` whenever you have it: it is the stronger dedup key (two companies can share a name), and an account with no website cannot be matched to a company for `jobs` and `job_change` discovery by website either. **A `POST /v1/find` accounts row is NOT postable verbatim.** It carries `id`, `website`, `linkedinUrl`, `industry`, `headcount`, `hq`, `fundingStage`, `foundedYear`, `companyType`, `description`, `why`, `known`, `state` in addition to `name` and `url`, and every one of those is refused. This API stores a name, a URL and a LinkedIn handle for an account and nothing else; the firmographics on a find row are unverified, reported by the search, and have nowhere to live here. Refusing them is deliberate — accepting and discarding them would have you believe the workspace holds research it does not. Project the result before importing: ``` jq '{accounts: [.data[] | select(.state == "selectable" and .url) | {name, url}]}' ``` If you need the firmographics, keep them client-side or in your CRM — `GET /v1/accounts` will never return them.
  - `name` string, required: The company's display name. Matching falls back to this only after `linkedinHandle` and `url`, so supply the stronger identifiers whenever you have them. It is also this account's LAST deduplication key on `POST /v1/accounts/batch`, and it counts only against a stored account that has neither a usable website nor a handle — two companies that share a name but each have a website are both created.
  - `url` string, uri, nullable: The company website. Must include a scheme when given, e.g. `https://stripe.com` — `stripe.com` alone is rejected. The hostname is also this account's deduplication key on `POST /v1/accounts/batch`. A row WITHOUT a url is deduplicated by NAME instead (case-insensitive, within your workspace), so it is not created twice — but a url is the stronger key, because two different companies can share a name. After `linkedinHandle`, this is the next way to identify the company: send it whenever you have it. OPTIONAL since TRA-2077. An account you only have a NAME for is created with no website (`url: null` on reads) rather than refused — the same thing `POST /v1/people` does with a `company` that carries no `domain`, and the same thing Trayo's own importer has always done. Two enforcement levels for one policy was the bug: this route refused rows the people route created.
  - `linkedinHandle` string: `linkedinHandle` is the bare LinkedIn company handle, e.g. `stripe` from `https://www.linkedin.com/company/stripe/`. Supply it whenever known: it is the strongest way to identify the company and is tried before `url` and `name`. It is also this account's SECOND deduplication key on `POST /v1/accounts/batch`, checked when the hostname does not match, so a row whose website is new to this workspace can still be skipped as a duplicate on its handle alone.

## Response `201`

Account created.

- AccountDto
  - `id` string, uuid, required
  - `name` string, required
  - `url` string, uri, nullable, required: The company's website as an absolute HTTP or HTTPS URL, or `null` when this workspace does not know one. `null` is expected rather than exceptional: an account created from a company NAME alone — by `POST /v1/people` with a `company` carrying no `domain`, or by a Trayo import — has no website until one is supplied. Set it with `PATCH /v1/accounts/{id}` (`trayo_update_account` over MCP). `logoUrl` is `null` for exactly these accounts, because it is derived from this field.
  - `logoUrl` string, nullable, required: The account's logo URL. Derived from `url`, so it is present for every account whose `url` is a usable domain and `null` otherwise. Cannot be set through this API. The URL returns image bytes (200) or redirects to them (302), so it is usable directly as an `<img src>`; it answers 404 when no logo resolves for the domain. Fetching it needs no API key.
  - `linkedinHandle` string, nullable, required
  - `starred` boolean, required
  - `scores` object, required: Trayo's own ranking of this account, published so you do not have to rebuild it by counting events. Both members are null on an account Trayo has not scored yet. This route does not sort by them — page through and sort client-side on `scores.timing.score` or `scores.activity.total`.
    - `timing` object, nullable, required: Null until Trayo has computed it. `calculatedAt` records when this score was last updated.
      - `score` integer, required: 0–100, NORMALIZED: this account's `raw` as a share of the highest `raw` in your workspace, rounded — the definition the product uses for its timing score. 100 is your hottest account right now, which means it moves when OTHER accounts move. Rank on this; threshold on `raw`.
      - `raw` number, required: The un-normalized score. How much your signals have fired at this account RECENTLY: each matched signal contributes its weight (1–3), decayed with a 30-day half-life, over a 90-day window, stacked and capped at 100. Stable for this account regardless of the rest of the workspace, so an absolute threshold on it holds still.
      - `calculatedAt` string, date-time, required
    - `activity` object, nullable, required: Null until Trayo has computed it. Recomputed after discovery runs; nothing in this API triggers it.
      - `total` number, required: Weighted sum of event significance and relevance with recency decay and a diversity bonus, over `lookbackDays`. Scale depends on the workspace's scoring configuration — compare accounts within a workspace, not across workspaces.
      - `eventCount` integer, required: Events that contributed.
      - `avgSignificance` number, nullable, required
      - `avgRelevance` number, nullable, required
      - `recencyBonus` number, required
      - `signalDiversityBonus` number, required
      - `lookbackDays` integer, required
      - `summary` string, nullable, required: A generated prose summary of what the contributing events add up to for this account — the "why", not just the number. Null until generated; regenerated when new events arrive.
      - `summaryGeneratedAt` string, date-time, nullable, required
      - `calculatedAt` string, date-time, required
  - `pointOfView` object, nullable, required: Trayo's reading of what this account is doing and why it matters to what your workspace sells — the "why" behind the account, where `scores.activity.summary` is the "why" behind the number. Every prediction cites the events it rests on, so you can check the reading against the record instead of taking it on trust. Null until the first discovery run produces events and the point of view is generated; regenerated when new events arrive. Also null when the stored copy predates the current shape — an out-of-date reading is withheld rather than served in pieces. Reading this route never generates one.
    - `predictions` object[], required: At most three, strongest first. Empty when the events do not support a direction — an honest answer, not a failure.
      - `point` string, required: One sentence: where the company appears to be heading, and the need that follows from it. Deliberately hedged ("likely", "appears to be") — it is an outside-in reading of public events, not a confirmed internal plan.
      - `evidence` object[], required: The events the prediction rests on, at least one and at most three. A prediction with no evidence is never written, so this is never empty.
        - `eventId` string, uuid, required: A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards.
        - `eventTitle` string, required
        - `eventDate` string, date, required: The date the event happened, `YYYY-MM-DD` — not a timestamp.
        - `eventSummary` string, nullable, required
        - `connection` string, required: One line on how THIS event supports the prediction — the causal or strategic link, not a restatement of the title.
    - `whyNow` object[], required: At most three. Always empty when `predictions` is empty — timing means nothing without a direction it is timely for.
      - `signalKey` string, required: The signal whose recent firing makes the timing matter. Matches a `signalKey` on `GET /v1/signals` and on the cited events.
      - `point` string, required: One line on why the timing matters now.
      - `events` object[], required: The last few events that matched this signal, most recent first.
        - `eventId` string, uuid, required: A public event id — read the whole event with `GET /v1/events/{id}`, or find it in `GET /v1/events?accountId=...`. The title, date and summary beside it are copies taken when the point of view was written, so fetch the event when you need its current form. That fetch can 404: the point of view is not rewritten when an event it cites is dismissed afterwards.
        - `eventTitle` string, required
        - `eventDate` string, date, required: The date the event happened, `YYYY-MM-DD` — not a timestamp.
    - `generatedAt` string, date-time, required: When this point of view was written.
  - `createdAt` string, date-time, required
  - `updatedAt` string, date-time, required

## Other responses

- `400`: Malformed body — e.g. a missing `name`, a `url` without a scheme, or an unknown field. Posting a `POST /v1/find` accounts row unprojected is the common case: `param` names the first extra key. `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.
- `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)
