{"openapi":"3.1.0","info":{"title":"Atlas API","version":"1.0.0","description":"## Introduction\n\nWelcome to the Atlas API. If you've never worked with an API before, don't worry - this page explains everything from scratch.\n\n### What is an API?\n\nAn API is a way for two different pieces of software to talk to each other. While we use the phone, emails or perhaps meet in person, software doesn't have that luxury (yet). Each software also speaks a completely different lanuage. So an API helps two pieces of software exchange datae in a common language\n\nHere's an analogy: imagine Atlas is a filing cabinet full of your recruitment data. Normally, you open the app, log in, and interact with that filing cabinet yourself - clicking through screens to find candidates, update projects, or pull reports. An API is like giving someone else (another piece of software) a set of keys and a rulebook, so they can access that same filing cabinet automatically, without a human needing to be involved.\n\nFor example:\n- When a candidate applies through your website, their details can be automatically added to Atlas as a new person record - no manual data entry needed.\n- A client portal you build could pull live project updates straight from Atlas, so clients always see the latest shortlist without you sending a single email.\n- If you use a job board or LinkedIn integration, candidate profiles can flow directly into Atlas the moment they express interest.\n\n### How does it work?\n\nEvery action in the API is called a **request**. A request is simply your software asking Atlas to do something - like \"give me a list of all candidates\" or \"create a new person record\".\n\nAtlas then sends back a **response** - the answer to that request. If you asked for a list of candidates, the response would contain that list of candidates and all their details, formatted in a way that software can easily read and process.\n\nRequests are sent to specific web addresses called **endpoints**. Each endpoint does one specific thing. This reference documents every endpoint available - what it does, what information you need to send along with your request, and exactly what the response will look like.\n\n> [!tip]\n> New to all of this? Watch our [quick walkthrough video](https://www.loom.com/share/7c71869c03274499a2a24c8cd422c32c) for a guided introduction before diving in.\n\n### Do I need to be a developer to use this?\n\nNot necessarily. There are several ways to build with an API today, depending on your comfort level:\n\n- **No-code tools** like Zapier or Make let you connect Atlas to other apps with a visual, drag-and-drop interface - no coding required.\n- **AI-assisted coding tools** like [Cursor](https://www.cursor.com), [Lovable](https://lovable.dev), or vibe coding tools mean you can describe what you want in plain English and have working code generated for you. Many non-developers are building real integrations this way.\n- **Traditional development** - if you have a developer on your team or work with an agency, hand them this reference and they'll know exactly what to build.\n\nHowever you approach it, this reference is written so that anyone can read through it, understand what's possible, and communicate it clearly to whoever is doing the building.\n\n## Authentication\n\nBefore the API will respond to any request, it needs to know who you are. This is done using an **API key** - a long string of characters that acts like a password, uniquely identifying your agency.\n\n### Getting your API key\n\nAPI keys are generated from the Atlas dashboard. Go to **Settings → Integrations → API Keys** and create a new key. Give it a name so you remember what it's for (e.g. \"Website integration\" or \"Zapier connection\").\n\n> [!warning]\n> Treat your API key like a password. Don't share it publicly, don't put it in a place others can see it, and don't commit it to a code repository. If a key is ever compromised, you can revoke it from the dashboard and generate a new one.\n\n### Using your API key\n\nEvery request you make to the API must include your key in the request headers. A **header** is just a piece of extra information sent alongside your request - think of it like writing your name on the outside of an envelope before sending it.\n\nThe header you need to include looks like this:\n\n```\nAuthorization: Bearer <your-api-key>\n```\n\nReplace `<your-api-key>` with the actual key you generated. For example, if your key was `abc123`, you would send:\n\n```\nAuthorization: Bearer abc123\n```\n\nDon't forget to add Bearer in front. If you're using a no-code tool like Zapier or Make, there will be a field where you paste your API key - you won't need to write this header yourself. If you're using an AI coding tool or working with a developer, share the key with them securely and they'll handle this for you.\n\n## Rate limits\n\nTo keep the platform fast for everyone, the API limits how many requests each agency can make per minute. Limits are counted per agency and per **tier** — reads, writes, and uploads each have an independent counter, so a burst of reads never eats into your write budget:\n\n| Tier | Applies to | Default limit |\n| --- | --- | --- |\n| `read` | All `GET` endpoints | 1200 requests / 60s |\n| `write` | `POST` / `PUT` / `PATCH` / `DELETE` endpoints | 400 requests / 60s |\n| `upload` | File uploads | 60 requests / 60s |\n\nPublic Jobs Portal endpoints (which need no API key) are limited per IP address using the same defaults. Higher limits can be arranged per agency — contact support if you need more.\n\n### Rate limit headers\n\n**Every** response — not just rejected ones — includes headers telling you where you stand:\n\n| Header | Meaning |\n| --- | --- |\n| `RateLimit-Limit` | Your quota for the current window on this endpoint's tier |\n| `RateLimit-Policy` | The quota policy as `<limit>;w=<window-seconds>`, e.g. `1200;w=60` |\n| `RateLimit-Remaining` | Requests you have left in the current window |\n| `RateLimit-Reset` | Seconds until the window resets (a delta in seconds, not a Unix timestamp) |\n\n### What happens when you hit the limit\n\nOnce the quota is used up, further requests are rejected with **HTTP 429** and a JSON body:\n\n```json\n{\n  \"status\": \"error\",\n  \"error\": \"Rate limit exceeded\",\n  \"tier\": \"read\",\n  \"retryAfterSec\": 60\n}\n```\n\nWait `retryAfterSec` seconds (equivalently, the `RateLimit-Reset` header) before retrying. A `Retry-After` header may also be present on 429 responses, but it is not guaranteed — prefer `RateLimit-Reset` or `retryAfterSec`. If you are building a high-volume sync, watch `RateLimit-Remaining` and slow down before it reaches zero rather than retrying on 429s.\n\n## Company contacts and prospects\n\nA **company contact** is a person linked to a company in a business-development context. A **prospect** is a company contact attached to an opportunity. People can exist on their own, but to attach a person to an opportunity as a prospect they must first have a current company. This guide walks through the full sequence.\n\n### Step 1 - create the company\n\n`POST /api/v1/companies` with a `name` **and at least one of `websiteUrl` or `linkedinUrl`** - a name alone is rejected with **422**, because a company without a website or LinkedIn identity cannot be found again through the `GET /api/v1/companies` lookup. Keep the returned `data.id` - you will link the person to it in the next step.\n\nIf you don't have a website or LinkedIn URL for the company, skip this step and pass `headline.company` (the name) in step 2 instead - Atlas resolves it to an existing company by name, or creates one when none matches.\n\n### Step 2 - create the person as a company contact\n\n`POST /api/v1/people` with:\n\n- `isContact: true` - marks the person as a company contact.\n- `headline.companyId` - the company ID from step 1. This links the contact to that exact company; no new company is created and no name matching happens.\n- `identities` - at least one non-website contact detail (email, phone, or LinkedIn), as on any person create.\n\n```json\n{\n  \"addedByEmail\": \"consultant@your-agency.com\",\n  \"isContact\": true,\n  \"headline\": { \"role\": \"Head of Engineering\", \"companyId\": \"<company-id-from-step-1>\" },\n  \"identities\": [{ \"type\": \"email\", \"value\": \"jane@example.com\" }]\n}\n```\n\nIf you only know the company by name, pass `headline.company` (the name) instead of `headline.companyId` - Atlas resolves it to an existing company by name, or creates one when none matches. Prefer `headline.companyId` whenever you created or looked up the company yourself, as name matching can create duplicate stub companies.\n\nThe company-contact link is created **synchronously**: the 201 response reports `queued.companyContact: 1` and the link is already present on an immediate read - there is no need to wait or poll before step 3.\n\nIf the create returns **409 Conflict**, the person already exists; take the returned `personId` and `PATCH /api/v1/people/{id}` with the same `headline` fields to link them, then continue. Note that `isContact` is not accepted on PATCH - if sent, it is silently ignored and no company-contact link is created. A headline company link is enough: step 3 accepts any prospect with one, and attaching the prospect creates the company-contact link itself.\n\n### Step 3 - create the opportunity with the person as prospect\n\n`POST /api/v1/opportunities` with `prospectIds: [\"<person-id-from-step-2>\"]` (plus the opportunity's own required fields).\n\nEvery person in `prospectIds` must have a current company - a headline company link or an existing company-contact link. A person without one makes the request fail with **422** and a message listing the offending person IDs. To recover, `PATCH /api/v1/people/{id}` with `headline.companyId` (or `headline.company`) for each listed person, then retry the opportunity create - the fix is effective immediately.\n\n### Contact ID types\n\nThree different IDs relate a person to other records. They are all UUIDs, so they look identical — but they are not interchangeable:\n\n| ID | Where you get it | Where you use it |\n| --- | --- | --- |\n| **Person ID** | `id` on GET `/api/v1/people`; `personId` on other resources | Person endpoints; `prospectIds` on opportunity create/update |\n| **CompanyContact junction ID** (the person↔company link) | `companyContact.id` on GET `/api/v1/people/{id}` | `companyContactIds` on POST/PATCH `/api/v1/projects` |\n| **Opportunity-person row ID** (the person↔opportunity link) | `prospects[].id` (and its alias `contactId`) on opportunity endpoints | Identifying that prospect link only — never accepted as a person ID or `companyContactIds` value |\n\nPassing the wrong type returns a 404, since no record with that ID exists in the expected table.","x-logo":{"url":"https://berg-atlas-public.s3.eu-west-1.amazonaws.com/atlas/logo.png","altText":"Atlas"}},"servers":[{"url":"/"}],"tags":[{"name":"Companies","description":"Endpoints for retrieving company data within your agency."},{"name":"Contacts","description":"Endpoints for listing company contacts (the link between a person and a company) within your agency."},{"name":"People","description":"Endpoints for managing people and their data within your agency."},{"name":"Projects","description":"Endpoints for listing and filtering projects within your agency."},{"name":"Candidates","description":"Endpoints for listing candidates across all projects within your agency. A candidate is a person at a specific status within a project pipeline."},{"name":"Jobs Portal","description":"Public endpoints for powering career pages and job boards. These endpoints do not require authentication and only return projects marked as public. Each project includes the `owner` (the lead consultant on the role) with their name and email so contact details can be shown on external job pages."},{"name":"Users","description":"Endpoints for listing users within your agency."},{"name":"Opportunities","description":"Endpoints for creating and managing opportunities within your agency."},{"name":"Prospects","description":"Endpoints for listing prospects — people attached to opportunities — as a flat, agency-wide list. Includes prospects created by the Spec CV / float workflow (surfaced via `opportunityType=speculative`)."},{"name":"Opportunity stage events","description":"A keyset-paginated feed of opportunity (business-development) stage transitions for incremental sync. This is distinct from Candidate stage events: it tracks opportunity/BD pipeline moves, not candidate/job pipeline moves."},{"name":"Campaigns","description":"Outreach campaigns are multi-step sequences (email, LinkedIn InMail/request, phone call, to-do) sent to a person on behalf of a recruiter.\n\nThere are two resources. An **outreach campaign** is a reusable *template* — a named sequence of steps tied to one project or opportunity — created with `POST /api/v1/campaigns`. **Launching** a template at a specific person with `POST /api/v1/campaigns/{id}/launch` creates that person's individual campaign, with every variable resolved and the first step scheduled.\n\n### Variables\n\nA step's `subject` and `body` may contain `{{variable}}` placeholders. There are three families:\n\n- **Static** (e.g. `{{person_first_name}}`, `{{client_name}}`, `{{job_role}}`) — resolved automatically from the person and the campaign's project/opportunity when you launch. You may override any of them per-launch. If a static placeholder resolves to empty and you did not override it, the launch is rejected.\n- **Time-sensitive** (`{{today}}`, `{{tomorrow}}`, `{{one_working_day}}`, …) and **step-sensitive** (`{{previous_step_day}}`) — resolved automatically at the moment each step is sent, so dates stay accurate. These cannot be overridden.\n- **AI** (`{{ai:<prompt>}}`) — a natural-language instruction you resolve yourself. The create and get-by-id responses list every distinct AI prompt under `aiVariables` as an array of `{ \"name\": \"<prompt>\", \"value\": null }`. Supply a value for each at launch by echoing the same array with values filled — `aiVariables: [{ \"name\": \"<prompt>\", \"value\": \"…\" }]` — matched by `name`.\n\n`{{manual:...}}` placeholders exist only in the in-app campaign editor and are **not supported over the API** — a template containing one is rejected on create (422) and cannot be launched.\n\n| Variable | Family | Resolved from | Overridable |\n| --- | --- | --- | --- |\n| `person_first_name` | static | Person's first name | yes |\n| `person_last_name` | static | Person's last name | yes |\n| `current_company` | static | Person's current employer | yes |\n| `client_name` | static | The project's company name | yes |\n| `job_role` | static | The project's job role | yes |\n| `job_link` | static | Public link to the project (the project must be public) | yes |\n| `target_company` | static | Person's company contact (business-development context) | yes |\n| `today` / `tomorrow` / `yesterday` | time-sensitive | The send date, as a weekday | no |\n| `time_of_day` | time-sensitive | `morning` / `afternoon` at send time | no |\n| `one_working_day` / `two_working_days` | time-sensitive | Working days after the send date | no |\n| `previous_step_day` | step-sensitive | Weekday the previous step completed | no |\n| `ai:<prompt>` | ai | You resolve it; supply at launch via `aiVariables` | required |\n\n### Duplicate rules\n\nLaunching returns **409 Conflict** when the person already has a clashing campaign. What counts as clashing depends on the campaign type:\n\n- **candidate_outreach** (project-linked): a person can have at most one active campaign on the project — any active campaign blocks a new launch, even from a different template. A completed campaign from the same template also blocks relaunching that template.\n- **prospect_outreach** (opportunity-linked): multiple concurrent campaigns are allowed for the same person on the same opportunity — only relaunching a template that is still active for them returns 409.\n- **speculative** (opportunity-linked): scoped to the launched person, like the other types — a person can have at most one active campaign per speculative opportunity. Relaunching the same active template is blocked (as for prospect_outreach), and so is launching a different template from the same speculative opportunity for that person (all its templates share the speculative person). Campaigns launched for other people never block: the same speculative person can be pitched to any number of people concurrently.\n\nDraft campaigns never block; \"active\" covers the active and ready statuses.\n\n### Example flow\n\n1. **Create a template** — `POST /api/v1/campaigns` with a step body like `Hi {{person_first_name}}, {{ai:write a one-line hook about their experience}}`.\n2. **Read back the AI prompts** — the create response (and `GET /api/v1/campaigns/{id}`) lists each step’s distinct prompts as `aiVariables: [{ \"name\": \"write a one-line hook about their experience\", \"value\": null }]`.\n3. **Resolve each prompt yourself** with your own AI — person context from `GET /api/v1/people/{personId}`, job context from the project/opportunity — producing a value for that prompt `name`.\n4. **Launch** — `POST /api/v1/campaigns/{id}/launch` with `{ \"personId\": \"…\", \"aiVariables\": [{ \"name\": \"write a one-line hook about their experience\", \"value\": \"ex-Stripe engineer who scaled payments to 1M TPS\" }] }`. The response returns each step with every variable resolved."},{"name":"Interviews","description":"Endpoints for retrieving interviews and their participants within your agency."},{"name":"Meetings","description":"Endpoints for listing meetings (both general meetings and interviews) and their participants within your agency."},{"name":"Emails","description":"Endpoints for listing email activity within your agency. Each row links an email to a person and carries metadata plus a snippet (not the full body). Private and hidden emails are excluded."},{"name":"Files","description":"Endpoints for uploading files to your agency."},{"name":"Placements","description":"Endpoints for retrieving placements (hires) and their fees within your agency."},{"name":"Fees","description":"Endpoints for retrieving project fees and their splits."},{"name":"Contracts","description":"Endpoints for retrieving contracts — ongoing contractor engagements with their rates, contacts, and fee attributions."},{"name":"Tasks","description":"Endpoints for listing, creating and updating tasks — actionable items assigned to a user, optionally linked to a person or company."},{"name":"Custom Attributes","description":"Endpoints for discovering custom attribute definitions (the schema) configured for each entity type — person, company, project, user, candidate, meeting. Use these to find which custom fields exist and what values are valid before reading or writing attribute data."},{"name":"Webhooks","description":"Atlas can send real-time notifications to your system when events happen — for example, when a person is created, a candidate moves stage, or a placement is recorded. These are called **webhooks**.\n\nWebhooks are delivered via [Svix](https://www.svix.com). To start receiving webhooks, go to **Settings → Integrations → Webhooks** in your Atlas dashboard and add an endpoint URL.\n\nEach webhook is an HTTP POST request to your endpoint with a JSON body containing:\n- `event` — the event type (e.g. `person.created`)\n- `occurredAt` — ISO 8601 timestamp\n- `data` — the event payload\n\nReturn any 2xx status code to acknowledge receipt. If delivery fails, Svix will retry automatically with exponential backoff."}],"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}},"headers":{"RateLimitLimit":{"description":"Request quota for the current window on the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Agencies with a negotiated override see their own limit here.","schema":{"type":"integer","example":1200}},"RateLimitPolicy":{"description":"Quota policy for the tier in `<limit>;w=<window-seconds>` form, e.g. `1200;w=60`.","schema":{"type":"string","example":"1200;w=60"}},"RateLimitRemaining":{"description":"Number of requests remaining in the current window. `0` means the next request will be rejected with a 429.","schema":{"type":"integer","example":1199}},"RateLimitReset":{"description":"Number of seconds until the current window resets and the quota is restored. This is a delta in seconds, not a Unix timestamp.","schema":{"type":"integer","example":60}},"RetryAfter":{"description":"Number of seconds to wait before retrying. This header is not guaranteed to be present on 429 responses — rely on `RateLimit-Reset` or the `retryAfterSec` field in the JSON body instead.","schema":{"type":"integer","example":60}}},"schemas":{"UpdateCompanyPayload":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500,"description":"Company name (max 500 characters)","example":"Acme Corp"},"relationship":{"type":"string","enum":["client","target","none"],"description":"Company relationship","example":"client"},"type":{"type":["string","null"],"enum":["educational","government","nonprofit","private","public"],"description":"Company type. Send null to clear."},"size":{"type":["string","null"],"enum":["1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+"],"description":"Company size bucket. Send null to clear.","example":"51-200"},"summary":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Short company summary. Empty strings are rejected — omit or send null to clear.","example":"B2B SaaS"},"overview":{"type":["string","null"],"minLength":1,"maxLength":16384,"description":"Long-form company description. Empty strings are rejected — omit or send null to clear."},"industry":{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":80},"maxItems":100,"description":"Industry labels (overwrites the existing set). Each label must be a known industry from the Atlas industry dictionary — matched case-insensitively and stored in the dictionary’s canonical casing; unknown labels return 422. Duplicates are silently dropped. Send null (or []) to clear.","example":["Software"]},"employeeCount":{"type":["integer","null"],"minimum":1,"maximum":10000000,"description":"Number of employees (precise, integer). Send null to clear.","example":150},"ticker":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Stock ticker symbol. Empty strings are rejected — omit or send null to clear.","example":"AAPL"},"location":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Display name / formatted address (e.g. \"London, UK\")","example":"London, UK"},"locality":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"City name","example":"London"},"region":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"State or region","example":"England"},"metro":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Metro area (People Data Labs convention, e.g. \"new york, new york\")","example":"new york, new york"},"country":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Country name","example":"United Kingdom"},"streetAddress":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 1","example":"123 Baker Street"},"addressLine2":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 2 (apartment, suite, unit, etc.)","example":"Suite 200"},"postalCode":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Postal / ZIP code","example":"NW1 6XE"},"raw":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Raw unstructured address text (used for geocoding)","example":"123 Baker Street, London NW1 6XE, United Kingdom"},"latitude":{"type":["number","null"],"minimum":-90,"maximum":90,"description":"Latitude coordinate (-90..90)","example":51.523767},"longitude":{"type":["number","null"],"minimum":-180,"maximum":180,"description":"Longitude coordinate (-180..180)","example":-0.158519}},"description":"Company primary location (overwrites the entire location). Send null to clear."},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["linkedin","phone","website","aboutme","angellist","behance","crunchbase","dribbble","ello","facebook","flickr","foursquare","github","gitlab","google","gravatar","indeed","instagram","klout","meetup","myspace","pinterest","quora","reddit","soundcloud","stackoverflow","twitter","vimeo","wordpress","xing","youtube","medium","scholar","other"],"description":"Identity type. Any value except `email` (companies have no email identities).","example":"linkedin"},"value":{"type":"string","minLength":1,"maxLength":2048,"description":"Identity value (URL, handle, etc.). URL-like values are normalised before storage (protocol and `www.` stripped, lower-cased), matching how identities are stored on create.","example":"https://linkedin.com/company/acme"},"primary":{"type":"boolean","description":"Whether this is the primary identity of its type. Defaults to false.","example":true}},"required":["type","value"]},"maxItems":100,"description":"Replace-all: when provided, the company’s existing identities are removed and this set inserted. Omit to leave identities unchanged. An identity value already owned by another company in the agency returns 409.","example":[{"type":"linkedin","value":"https://linkedin.com/company/acme","primary":true}]},"customAttributes":{"type":"array","items":{"type":"object","properties":{"customAttributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID — must belong to the agency at the correct scope."},"optionId":{"type":["string","null"],"format":"uuid","description":"Required for `options`-type attributes — set to the chosen option's UUID. Mutually exclusive with `value`. For multi-select (`multipleValues: true`), repeat the entry once per chosen optionId."},"value":{"anyOf":[{"type":"string","minLength":1,"maxLength":16384},{"type":"number"},{"type":"null"}],"description":"Attribute value. Shape depends on the attribute `type`:\n- `text_line` / `text_block`: non-empty string (trimmed, max 16384 chars).\n- `integer` / `number_input`: JSON number, integer only, must fit Postgres int32 (-2147483648..2147483647).\n- `date`: string `YYYY-MM-DD`, calendar-validated.\n- `options`: do not send `value` — use `optionId` instead.\nMutually exclusive with `optionId`. Sending neither is rejected (422).","example":"Some text value"}},"required":["customAttributeId"],"description":"Custom attribute value"},"maxItems":100,"description":"Custom attribute values to set. Replace-per-attribute: for each customAttributeId present, the company’s existing values for that attribute are removed and these inserted. Attributes not listed are untouched. Same entry shape as POST /companies.","example":[{"customAttributeId":"550e8400-e29b-41d4-a716-446655440000","value":"Tier 1"},{"customAttributeId":"f47ac10b-58cc-4372-a567-0e02b2c3d479","optionId":"9b2e4d6a-1c3f-4a8b-9e7d-2f1a6c5b4d3e"}]},"ownerEmail":{"type":"string","maxLength":255,"format":"email","description":"Email of the Atlas user the update is attributed to (acting user). Optional — must match a user in the agency when supplied, otherwise 404.","example":"recruiter@agency.com"}},"description":"Partial update of a company"},"CreatePersonPayload":{"type":"object","properties":{"firstName":{"type":["string","null"],"minLength":1,"maxLength":80,"description":"First name","example":"Michael"},"lastName":{"type":["string","null"],"minLength":1,"maxLength":80,"description":"Last name","example":"Scott"},"isContact":{"type":"boolean","default":false,"description":"If true, creates a company contact","example":false},"gender":{"type":"string","enum":["male","female"],"description":"Gender","example":"male"},"addedByEmail":{"type":"string","maxLength":255,"format":"email","description":"Email of the user adding this person","example":"jordan@recruitwithatlas.com"},"source":{"type":"object","properties":{"system":{"type":"string","minLength":1,"maxLength":255,"description":"Source system name (sets people.sourced)","example":"api"},"externalId":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"External ID from the source system. Unique per person within the agency: a (system, externalId) pair identifies at most one person and is the highest-priority conflict check on POST /api/v1/people. Supplying an externalId already stored on another person returns 409 (on POST and PATCH) with that person's id.","example":"124456"}},"required":["system"],"description":"Source system and external ID (defaults to api). The (system, externalId) pair is unique per person within the agency — supplying one that already belongs to a person returns 409."},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["email","phone","linkedin","linkedin_salesnav","linkedin_recruiter","website"],"description":"Identity type","example":"email"},"value":{"type":"string","minLength":1,"maxLength":255,"description":"Identity value","example":"mscott@gmail.com"},"isPersonal":{"type":"boolean","description":"Whether this is a personal identity","example":true},"isPrimary":{"type":"boolean","description":"Whether this is the primary/favourite identity","example":true}},"required":["type","value"]},"minItems":1,"maxItems":20,"description":"Person identities (at least one non-website identity required, max 20)"},"headline":{"type":"object","properties":{"role":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Current role/job title","example":"Regional Manager"},"company":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Current company name. Resolved to an existing company by name (or a new one is created when none matches) and linked to the person, unless `companyId` is also provided (which takes precedence) or explicitly set to null (which stores the name as text only, without a company link).","example":"Dunder Mifflin"},"companyId":{"type":["string","null"],"format":"uuid","description":"Atlas company ID to link this headline/contact to an existing company. When provided, no new company is created and name matching is skipped. Takes precedence over `company`: the stored company name is set to the linked company’s name, overwriting a contradictory `company` value. On PATCH, an existing company-contact link is re-pointed to this company; as when changing the contact’s company in the app, this detaches the contact from projects linked under the old company.","example":"550e8400-e29b-41d4-a716-446655440000"},"roleStartedAt":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"When the current role started (YYYY-MM-DD)","example":"2023-04-01"}},"description":"Current headline information"},"location":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Display name / formatted address (e.g. \"London, UK\")","example":"London, UK"},"locality":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"City name","example":"London"},"region":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"State or region","example":"England"},"metro":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Metro area (People Data Labs convention, e.g. \"new york, new york\")","example":"new york, new york"},"country":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Country name","example":"United Kingdom"},"streetAddress":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 1","example":"123 Baker Street"},"addressLine2":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 2 (apartment, suite, unit, etc.)","example":"Suite 200"},"postalCode":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Postal / ZIP code","example":"NW1 6XE"},"raw":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Raw unstructured address text (used for geocoding)","example":"123 Baker Street, London NW1 6XE, United Kingdom"},"latitude":{"type":["number","null"],"minimum":-90,"maximum":90,"description":"Latitude coordinate (-90..90)","example":51.523767},"longitude":{"type":["number","null"],"minimum":-180,"maximum":180,"description":"Longitude coordinate (-180..180)","example":-0.158519}},"description":"Person address/location"},"compensation":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["actual","expected"],"description":"Compensation type","example":"actual"},"basicSalary":{"type":"number","exclusiveMinimum":0,"description":"Base salary amount","example":120000},"totalSalary":{"type":"number","exclusiveMinimum":0,"description":"Total salary amount (base + bonus)","example":150000},"currency":{"type":"string","enum":["USD","EUR","JPY","GBP","AUD","CAD","CHF","CNY","HKD","NZD","SEK","NOK","MXN","SGD","RUB","ZAR","TRY","BRL","INR","KRW","DKK","PLN","ILS","HUF","CZK","RON","THB","MYR","IDR","VND","PHP","SAR","AED","QAR","KWD","JOD","CLP","COP","PEN","ARS","UYU","CRC","PKR","BDT","LKR","EGP","NGN","TWD","KES","GHS","UGX","TZS","MAD","BWP","BGN","UAH","KZT","GEL","ISK","BHD","OMR"],"description":"Currency code","example":"USD"},"basis":{"type":"string","enum":["gross","net"],"default":"gross","description":"Gross or net","example":"gross"},"date":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Date this compensation data is relevant to (YYYY-MM-DD)","example":"2024-06-01"}},"required":["type","currency","date"]},"maxItems":100,"description":"Compensation records"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"customAttributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID — must belong to the agency at the correct scope."},"optionId":{"type":["string","null"],"format":"uuid","description":"Required for `options`-type attributes — set to the chosen option's UUID. Mutually exclusive with `value`. For multi-select (`multipleValues: true`), repeat the entry once per chosen optionId."},"value":{"anyOf":[{"type":"string","minLength":1,"maxLength":16384},{"type":"number"},{"type":"null"}],"description":"Attribute value. Shape depends on the attribute `type`:\n- `text_line` / `text_block`: non-empty string (trimmed, max 16384 chars).\n- `integer` / `number_input`: JSON number, integer only, must fit Postgres int32 (-2147483648..2147483647).\n- `date`: string `YYYY-MM-DD`, calendar-validated.\n- `options`: do not send `value` — use `optionId` instead.\nMutually exclusive with `optionId`. Sending neither is rejected (422).","example":"Some text value"}},"required":["customAttributeId"],"description":"Custom attribute value"},"maxItems":100,"default":[],"description":"Custom attribute values. Each entry sets `customAttributeId` plus either `value` (text/number/date) or `optionId` (options-type) — never both. For options-type attributes with multipleValues=true, repeat the same customAttributeId with distinct optionIds.\n\nDeprecated shape still accepted for backward compatibility: `{ attributeId, valueString | valueInt | valueDate }` is auto-mapped to `{ customAttributeId, value }`. Send the canonical shape below.","example":[{"customAttributeId":"550e8400-e29b-41d4-a716-446655440000","value":"Some text value"},{"customAttributeId":"6f9619ff-8b86-d011-b42d-00cf4fc964ff","value":42},{"customAttributeId":"c9bf9e57-1685-4c89-bafb-ff5af830be8a","value":"2025-06-01"},{"customAttributeId":"f47ac10b-58cc-4372-a567-0e02b2c3d479","optionId":"9b2e4d6a-1c3f-4a8b-9e7d-2f1a6c5b4d3e"}]},"experience":{"type":"array","items":{"type":"object","properties":{"companyName":{"type":"string","minLength":1,"maxLength":255,"description":"Company name","example":"Dunder Mifflin"},"companyId":{"type":["string","null"],"format":"uuid","description":"Atlas company ID to link this experience to an existing company. When provided, no new company is created and name/domain/LinkedIn matching is skipped. On update, an explicit `null` unlinks the company; when present (id or null) it takes precedence over `companyName` matching.","example":"550e8400-e29b-41d4-a716-446655440000"},"companyLinkedinId":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Company LinkedIn ID","example":"12345678"},"companyDomain":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Company website domain (bare domain; full URLs are accepted and normalized to their hostname)","example":"dundermifflin.com"},"role":{"type":"string","minLength":1,"maxLength":255,"description":"Job title/role","example":"Regional Manager"},"description":{"type":["string","null"],"minLength":1,"maxLength":16384,"description":"Role description","example":"Responsible for managing Scranton branch."},"startDate":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Start date (YYYY-MM-DD)","example":"2021-04-01"},"endDate":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"End date (YYYY-MM-DD, null = current)","example":null}},"required":["companyName","role","startDate"]},"maxItems":100,"description":"Work experience records"},"education":{"type":"array","items":{"type":"object","properties":{"institutionName":{"type":"string","minLength":1,"maxLength":255,"description":"Institution name","example":"University of Scranton"},"degree":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Degree name","example":"Bachelor of Science"},"fieldOfStudy":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Field of study","example":"Business Administration"},"grade":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Grade/GPA","example":"3.4 GPA"},"description":{"type":["string","null"],"minLength":1,"maxLength":16384,"description":"Description","example":"Focus on management and sales."},"startDate":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Start date (YYYY-MM-DD)","example":"2012-09-01"},"endDate":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"End date (YYYY-MM-DD)","example":"2016-06-30"}},"required":["institutionName"]},"maxItems":100,"description":"Education records"}},"required":["addedByEmail","identities"],"description":"Create a person from an external source. Strict create — if the payload matches an existing person the request returns 409 with that person's id; use PATCH /api/v1/people/{id} to update them."},"LookupPeoplePayload":{"type":"object","properties":{"identities":{"type":"array","items":{"type":"string","description":"Identity value (email, phone, or LinkedIn URL)","example":"candidate@example.com"},"minItems":1,"maxItems":100,"description":"List of identity strings to look up (max 100). Type is auto-detected from the value pattern.","example":["test@example.com","+380631817386","www.linkedin.com/in/pavel","/in/jshlosberg","in/jane-doe"]}},"required":["identities"]},"UpdatePersonPayload":{"type":"object","properties":{"firstName":{"type":["string","null"],"minLength":1,"maxLength":80,"description":"First name","example":"Michael"},"lastName":{"type":["string","null"],"minLength":1,"maxLength":80,"description":"Last name","example":"Scott"},"gender":{"type":"string","enum":["male","female"],"description":"Gender","example":"male"},"candidateStatus":{"type":"string","enum":["omitted","applied","sourced","regular"],"description":"Candidate status classification: omitted (outbound-only, unresponsive), applied (created from an application), sourced (created via sourcing, still in a sourcing stage), regular (everyone else).","example":"regular"},"overview":{"type":["string","null"],"minLength":1,"maxLength":16384,"description":"Free-text overview/summary of the person. `null` clears it.","example":"Senior backend engineer, 10 years in fintech, open to contract roles."},"addedByEmail":{"type":"string","maxLength":255,"format":"email","description":"Email of the Atlas user the update is attributed to. Optional — required only when updating identities. If omitted, the update is unattributed.","example":"jordan@recruitwithatlas.com"},"source":{"type":"object","properties":{"system":{"type":"string","minLength":1,"maxLength":255,"description":"Source system name (sets people.sourced)","example":"api"},"externalId":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"External ID from the source system. Unique per person within the agency: a (system, externalId) pair identifies at most one person and is the highest-priority conflict check on POST /api/v1/people. Supplying an externalId already stored on another person returns 409 (on POST and PATCH) with that person's id.","example":"124456"}},"required":["system"],"description":"Source system and external ID"},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["email","phone","linkedin","linkedin_salesnav","linkedin_recruiter","website"],"description":"Identity type","example":"email"},"value":{"type":"string","minLength":1,"maxLength":255,"description":"Identity value","example":"mscott@gmail.com"},"isPersonal":{"type":"boolean","description":"Whether this is a personal identity","example":true},"isPrimary":{"type":"boolean","description":"Whether this is the primary/favourite identity","example":true}},"required":["type","value"]},"minItems":1,"maxItems":20,"description":"Identities to add (additive — existing identities are never removed; at least one non-website identity required, max 20). An identity already owned by another person returns 409. Requires addedByEmail."},"headline":{"type":"object","properties":{"role":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Current role/job title","example":"Regional Manager"},"company":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Current company name. Resolved to an existing company by name (or a new one is created when none matches) and linked to the person, unless `companyId` is also provided (which takes precedence) or explicitly set to null (which stores the name as text only, without a company link).","example":"Dunder Mifflin"},"companyId":{"type":["string","null"],"format":"uuid","description":"Atlas company ID to link this headline/contact to an existing company. When provided, no new company is created and name matching is skipped. Takes precedence over `company`: the stored company name is set to the linked company’s name, overwriting a contradictory `company` value. On PATCH, an existing company-contact link is re-pointed to this company; as when changing the contact’s company in the app, this detaches the contact from projects linked under the old company.","example":"550e8400-e29b-41d4-a716-446655440000"},"roleStartedAt":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"When the current role started (YYYY-MM-DD)","example":"2023-04-01"}},"description":"Current headline information"},"location":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Display name / formatted address (e.g. \"London, UK\")","example":"London, UK"},"locality":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"City name","example":"London"},"region":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"State or region","example":"England"},"metro":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Metro area (People Data Labs convention, e.g. \"new york, new york\")","example":"new york, new york"},"country":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Country name","example":"United Kingdom"},"streetAddress":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 1","example":"123 Baker Street"},"addressLine2":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 2 (apartment, suite, unit, etc.)","example":"Suite 200"},"postalCode":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Postal / ZIP code","example":"NW1 6XE"},"raw":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Raw unstructured address text (used for geocoding)","example":"123 Baker Street, London NW1 6XE, United Kingdom"},"latitude":{"type":["number","null"],"minimum":-90,"maximum":90,"description":"Latitude coordinate (-90..90)","example":51.523767},"longitude":{"type":["number","null"],"minimum":-180,"maximum":180,"description":"Longitude coordinate (-180..180)","example":-0.158519}},"description":"Person address/location"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"customAttributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID — must belong to the agency at the correct scope."},"optionId":{"type":["string","null"],"format":"uuid","description":"Required for `options`-type attributes — set to the chosen option's UUID. Mutually exclusive with `value`. For multi-select (`multipleValues: true`), repeat the entry once per chosen optionId."},"value":{"anyOf":[{"type":"string","minLength":1,"maxLength":16384},{"type":"number"},{"type":"null"}],"description":"Attribute value. Shape depends on the attribute `type`:\n- `text_line` / `text_block`: non-empty string (trimmed, max 16384 chars).\n- `integer` / `number_input`: JSON number, integer only, must fit Postgres int32 (-2147483648..2147483647).\n- `date`: string `YYYY-MM-DD`, calendar-validated.\n- `options`: do not send `value` — use `optionId` instead.\nMutually exclusive with `optionId`. Sending neither is rejected (422).","example":"Some text value"}},"required":["customAttributeId"],"description":"Custom attribute value"},"maxItems":100,"description":"Custom attribute values to set. Replace-per-attribute: for each customAttributeId present, the person’s existing values for that attribute are removed and these inserted. Attributes not listed are untouched. Same entry shape as POST /people.","example":[{"customAttributeId":"550e8400-e29b-41d4-a716-446655440000","value":"Some text value"},{"customAttributeId":"f47ac10b-58cc-4372-a567-0e02b2c3d479","optionId":"9b2e4d6a-1c3f-4a8b-9e7d-2f1a6c5b4d3e"}]}},"description":"Synchronous partial update of a person"},"UpdateExperiencePayload":{"type":"object","properties":{"companyName":{"type":"string","minLength":1,"maxLength":255,"description":"Company name","example":"Dunder Mifflin"},"companyId":{"type":["string","null"],"format":"uuid","description":"Atlas company ID to link this experience to an existing company. When provided, no new company is created and name/domain/LinkedIn matching is skipped. On update, an explicit `null` unlinks the company; when present (id or null) it takes precedence over `companyName` matching.","example":"550e8400-e29b-41d4-a716-446655440000"},"companyLinkedinId":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Company LinkedIn ID","example":"12345678"},"companyDomain":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Company website domain (bare domain; full URLs are accepted and normalized to their hostname)","example":"dundermifflin.com"},"role":{"type":"string","minLength":1,"maxLength":255,"description":"Job title/role","example":"Regional Manager"},"description":{"type":["string","null"],"minLength":1,"maxLength":16384,"description":"Role description","example":"Responsible for managing Scranton branch."},"startDate":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Start date (YYYY-MM-DD)","example":"2021-04-01"},"endDate":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"End date (YYYY-MM-DD, null = current)","example":null}},"description":"Partial update of a single experience"},"CreateNotePayload":{"type":"object","properties":{"email":{"type":"string","maxLength":255,"format":"email","description":"Person's email address. Provide exactly one of `email`, `personId`, `linkedinUrl`, or `phone`.","example":"person@example.com"},"personId":{"type":"string","format":"uuid","description":"Atlas person ID. Provide exactly one of `email`, `personId`, `linkedinUrl`, or `phone`.","example":"123e4567-e89b-12d3-a456-426614174000"},"linkedinUrl":{"type":"string","minLength":1,"description":"Person's LinkedIn profile URL. Provide exactly one of `email`, `personId`, `linkedinUrl`, or `phone`.","example":"linkedin.com/in/john-doe"},"phone":{"type":"string","minLength":1,"description":"Person's phone number. Provide exactly one of `email`, `personId`, `linkedinUrl`, or `phone`.","example":"+14155551234"},"note":{"type":"string","minLength":1,"maxLength":16384,"description":"Note text to create","example":"Called and left a voicemail."},"ownerEmail":{"type":"string","maxLength":255,"format":"email","description":"Note owner's email address","example":"recruiter@agency.com"},"projectId":{"type":"string","format":"uuid","description":"Atlas project ID to scope the note to the person's candidacy on that project. Omit for a person-level note.","example":"9f8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d"}},"required":["note","ownerEmail"],"description":"Create a note attached to a person"},"CreateCandidatePayload":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person ID to add as candidate","example":"550e8400-e29b-41d4-a716-446655440001"},"statusId":{"type":"string","format":"uuid","description":"Target status ID. When provided, stageId is ignored","example":"550e8400-e29b-41d4-a716-446655440002"},"stageId":{"type":"string","format":"uuid","description":"Target stage ID — first status of this stage will be used. Ignored when statusId is provided","example":"550e8400-e29b-41d4-a716-446655440003"}},"required":["personId"]},"CreateOpportunityPayload":{"type":"object","properties":{"name":{"type":["string","null"],"description":"Opportunity name (max 500 chars)","example":"Acme Corp — Senior Engineer"},"notes":{"type":["string","null"],"description":"Free-text notes (max 10 000 chars). Stored on the opportunity itself and returned as the top-level `notes` string on GET — not as an entry in the `opportunityNotes` array, which holds individual note records added by users in Atlas.","example":"Warm intro via LinkedIn"},"aiSummary":{"type":["string","null"],"description":"AI-generated summary for the opportunity (max 10 000 chars)","example":"High-fit candidate based on past placements."},"value":{"type":["integer","null"],"minimum":0,"maximum":1000000000,"description":"Monetary value (integer, 0–1 000 000 000)","example":25000},"ownerId":{"type":["string","null"],"format":"uuid","description":"Owner user ID (defaults to authenticated user)","example":"550e8400-e29b-41d4-a716-446655440001"},"candidateId":{"type":["string","null"],"format":"uuid","description":"Person being specced out. Sets type to speculative","example":"550e8400-e29b-41d4-a716-446655440005"},"prospectIds":{"type":["array","null"],"items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Person IDs to attach as prospects (max 50) — the `id` from GET /api/v1/people. NOT CompanyContact junction IDs (`companyContact.id`) and NOT the `prospects[].id` rows returned by opportunity endpoints. Merged IDs are resolved to their canonical person automatically."},"companyIds":{"type":["array","null"],"items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Target companies (max 50)"},"jobLeadIds":{"type":["array","null"],"items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Projects to link as job leads (max 50)"}},"additionalProperties":false},"UpdateOpportunityPayload":{"type":"object","properties":{"prospectIds":{"type":["array","null"],"items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Person IDs to attach as prospects (max 50) — the `id` from GET /api/v1/people, not CompanyContact junction IDs or `prospects[].id` rows. Additive — already-linked people are skipped. Merged IDs are resolved to their canonical person automatically."},"companyIds":{"type":["array","null"],"items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Target companies to add (max 50). Additive — already-linked companies are skipped."},"jobLeadIds":{"type":["array","null"],"items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Projects to link as job leads (max 50). Additive — already-linked projects are skipped."}},"additionalProperties":false}},"parameters":{}},"paths":{"/api/v1/companies":{"get":{"summary":"List companies","description":"Returns a paginated list of companies under the caller's agency, newest first.\n\n**Filters:**\n- `relationship` — comma-separated relationships (`client`, `target`, `none`). **Defaults to `client,target` — `none` companies are excluded unless you request them explicitly** (e.g. `?relationship=none` or `?relationship=client,target,none`). `none` is the relationship of every company auto-created from a person’s experience, enrichment, or import, so they are omitted from the sync surface by default.\n- `name` — case-insensitive partial match on the company name.\n- `createdAfter` / `createdBefore` — only companies created within the range (inclusive).\n- `updatedAfter` / `updatedBefore` — only companies updated within the range (inclusive). Use `updatedAfter` as an incremental-sync cursor: persist the maximum `updatedAt` you receive and pass it back on the next poll to fetch only what changed (upsert by `id` to stay idempotent).\n\n**Deletions (tombstones):**\nSoft-deleted companies are excluded by default. Pass `includeDeleted=true` to also receive deleted companies as tombstones — each carries a populated `deletedAt` (live rows have `deletedAt: null`). Combine `includeDeleted=true` with `updatedAfter` to incrementally pick up deletions: a soft-delete bumps `updatedAt`, so the deleted row resurfaces in the next poll with `deletedAt` set.\n\n**Pagination:**\nBy default the list is offset-paginated (`page` / `pageSize`) and the response's `pagination` object carries `page`, `pageSize`, `total`, and `totalPages`.\n- For large agencies, full crawls, or incremental syncs, prefer **keyset (cursor) pagination**: set `paginate=cursor`. In this mode `page` is ignored, the expensive total `COUNT` is skipped, and paging stays stable while the list changes underneath a long sync. For the default newest-first crawl (live rows) it also stays fast at any depth; combining cursor mode with `includeDeleted=true` or a narrow `updatedAfter`/`updatedBefore` window can still be slow on very large agencies (those paths are not yet index-optimized). The response `pagination` object then carries `pageSize`, `hasMore`, and `nextCursor` (`total`/`totalPages` are omitted). Request page 1 with just `paginate=cursor` (no cursor), then replay `pagination.nextCursor.cursorDate` + `cursorId` on each subsequent request until `nextCursor` is `null` (equivalently `hasMore` is `false`). Both cursor halves must be sent together — a half-cursor is a `422`. When `paginate` is omitted, a `cursorDate` + `cursorId` pair activates cursor mode on its own; combining a cursor pair with an explicit `paginate=offset` is contradictory and is rejected with `422`.\n\nEach item has the same shape as `GET /api/v1/companies/{id}`, including location, identities, custom attributes, and owners.","tags":["Companies"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Comma-separated company relationships to filter by (`client`, `target`, `none`).\n\n**Default (when omitted): `client,target` — `none` companies are excluded.** `none` is the relationship of every company auto-created from a person’s work experience, enrichment, or import (the large majority of companies), so the sync surface excludes them by default. To include them, request `none` explicitly — e.g. `?relationship=none` or `?relationship=client,target,none`.","example":"client,target"},"required":false,"name":"relationship","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":500,"description":"Filter by company name (case-insensitive partial match)","example":"Acme"},"required":false,"name":"name","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only companies created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only companies created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only companies updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only companies updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted companies as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"string","enum":["asc","desc"],"default":"desc","description":"Sort order by `createdAt` (record creation time) — `desc` (default, newest first) or `asc` (oldest first). `id` is used as a stable tie-breaker, applied in the same direction.","example":"desc"},"required":false,"name":"order","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"},{"schema":{"type":"string","enum":["offset","cursor"],"description":"Pagination strategy. `offset` (the default when omitted) uses `page`/`pageSize` and returns `total`/`totalPages`. `cursor` uses keyset pagination (`cursorDate` + `cursorId`) and skips the expensive total `COUNT` — recommended for full crawls and incremental syncs; it stays fast at any depth for the default newest-first crawl, though `includeDeleted=true` or a narrow `updatedAfter`/`updatedBefore` window can still be slow on very large agencies. When `paginate` is omitted, passing `cursorDate`/`cursorId` infers `cursor`; combining an explicit `paginate=offset` with a cursor pair is rejected with `422`.","example":"offset"},"required":false,"name":"paginate","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Keyset pagination cursor (used with `paginate=cursor`): pass back the `pagination.nextCursor.cursorDate` from the previous response. Must be sent together with `cursorId`. Omit for the first page. When cursor mode is active `page` is ignored and `total`/`totalPages` are omitted from the response — walk pages until `nextCursor` is `null`.","example":"2026-01-05T12:00:00.123456Z"},"required":false,"name":"cursorDate","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Keyset pagination cursor id (used with `paginate=cursor`): pass back the `pagination.nextCursor.cursorId` from the previous response. Must be sent together with `cursorDate`.","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"cursorId","in":"query"}],"responses":{"200":{"description":"Paginated list of companies","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Company name","example":"Acme Corp"},"websiteUrl":{"type":["string","null"],"description":"The company's primary website as a canonical `https://…` URL, derived from the primary `website` identity (or the first one if none is flagged primary). `null` when the company has no website identity. Accepted verbatim by `GET /companies/find-one?website=`.","example":"https://acme.com"},"linkedinUrl":{"type":["string","null"],"description":"The company's primary LinkedIn page as a canonical `https://…` URL, derived from the primary `linkedin` identity (or the first one if none is flagged primary). `null` when the company has no linkedin identity. Accepted verbatim by `GET /companies/find-one?linkedin_url=`.","example":"https://linkedin.com/company/acme"},"relationship":{"type":"string","enum":["client","target","none"],"description":"Company relationship","example":"client"},"type":{"type":["string","null"],"enum":["educational","government","nonprofit","private","public"],"description":"Company type"},"size":{"type":["string","null"],"enum":["1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+"],"description":"Company size"},"industry":{"type":["array","null"],"items":{"type":"string"},"description":"Array of industry labels","example":["Technology"]},"summary":{"type":["string","null"],"description":"Short company summary"},"overview":{"type":["string","null"],"description":"Long-form company description"},"logo":{"type":["string","null"],"description":"Logo image URL"},"employeeCount":{"type":["integer","null"],"description":"Number of employees","example":150},"ticker":{"type":["string","null"],"description":"Stock ticker symbol","example":"AAPL"},"location":{"type":["object","null"],"properties":{"formattedAddress":{"type":["string","null"],"description":"Provider-normalized \"city, region, country\" string","example":"San Francisco, California, United States"},"city":{"type":["string","null"],"description":"City","example":"San Francisco"},"region":{"type":["string","null"],"description":"State / province","example":"California"},"country":{"type":["string","null"],"description":"Country","example":"United States"},"metro":{"type":["string","null"],"description":"Metro area","example":"San Francisco Bay Area"},"streetAddress":{"type":["string","null"],"description":"Street address line 1"},"addressLine2":{"type":["string","null"],"description":"Street address line 2"},"postalCode":{"type":["string","null"],"description":"Postal / zip code"},"raw":{"type":["string","null"],"description":"Unstructured location string (e.g. provider-normalized address text)","example":"London, England, United Kingdom"},"latitude":{"type":["number","null"],"description":"Latitude in decimal degrees","example":37.7749},"longitude":{"type":["number","null"],"description":"Longitude in decimal degrees","example":-122.4194}},"required":["formattedAddress","city","region","country","metro","streetAddress","addressLine2","postalCode","raw","latitude","longitude"],"description":"Company primary location, or `null` when no usable location data is on file"},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Identity type","example":"linkedin"},"value":{"type":"string","description":"Normalised identity value, stored scheme-less (protocol and `www.` stripped) so duplicates collapse to one company. Use the top-level `websiteUrl` / `linkedinUrl` fields for ready-to-use `https://…` URLs.","example":"linkedin.com/company/acme"},"primary":{"type":"boolean","description":"Whether this is the primary identity of its type"}},"required":["type","value","primary"]},"description":"Company identities (linkedin, website, etc.)"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Custom attribute values"},"owners":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Atlas user id of the owner"},"name":{"type":"string","description":"Owner's full name","example":"Jane Smith"},"email":{"type":"string","description":"Owner's email address","example":"jane@agency.com"}},"required":["id","name","email"]},"description":"Atlas users who own this company relationship (its relationship managers). A company can have several owners; the array is empty when none are assigned. Read-only — owners are managed in the Atlas app."},"createdAt":{"type":"string","description":"ISO 8601 creation timestamp"},"updatedAt":{"type":"string","description":"ISO 8601 last modification timestamp"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live companies; populated for tombstones (only returned when `includeDeleted=true` on the list endpoint)"}},"required":["id","name","websiteUrl","linkedinUrl","relationship","type","size","industry","summary","overview","logo","employeeCount","ticker","location","identities","customAttributes","owners","createdAt","updatedAt","deletedAt"]}},"pagination":{"anyOf":[{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":42},"totalPages":{"type":"integer","description":"Total number of pages","example":2}},"required":["page","pageSize","total","totalPages"]},{"type":"object","properties":{"pageSize":{"type":"integer","description":"Items per page","example":25},"hasMore":{"type":"boolean","description":"Whether another page is available after this one","example":true},"nextCursor":{"type":["object","null"],"properties":{"cursorDate":{"type":"string","description":"Pass back as `cursorDate` to fetch the next page"},"cursorId":{"type":"string","format":"uuid","description":"Pass back as `cursorId` to fetch the next page"}},"required":["cursorDate","cursorId"],"description":"Cursor for the next page. `null` when there are no more results (equivalently `hasMore` is `false`)."}},"required":["pageSize","hasMore","nextCursor"]}],"description":"Offset shape (`page`/`pageSize`/`total`/`totalPages`) by default, or the keyset shape (`pageSize`/`hasMore`/`nextCursor`) when cursor mode is active — i.e. `paginate=cursor` is set (the first page needs no cursor) or a `cursorDate` + `cursorId` pair is supplied."}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Create a company","description":"Creates a new company under the caller's agency. The endpoint:\n\n- Requires **at least one of `websiteUrl` or `linkedinUrl`** — companies created without any identity become unreachable via `GET /companies` lookup. Submitting both is allowed; both are stored as separate identities.\n- Stores `websiteUrl` and `linkedinUrl` as CompanyIdentity rows of type `website` / `linkedin` (the `identities` array is read-only output).\n- Returns **409 Conflict** when an existing company in the same agency already owns the supplied `websiteUrl`, `linkedinUrl`, or LinkedIn id — the underlying create service silently dedups, REST surfaces the conflict.\n- Does **not** accept a caller-supplied `logo` URL — any `logo` field in the body is silently stripped. The logo is populated downstream by automated enrichment (the create endpoint does not dereference caller URLs to avoid SSRF risk). Triggers enrichment / geolocation jobs in the background; PDL-derived fields (`industry`, `ticker`, location subfields) may continue to populate after the response is returned.\n\nResponse shape matches `GET /api/v1/companies/{id}` minus `logo`. Read `logo` via `GET /companies/{id}` once enrichment finishes.","tags":["Companies"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500,"description":"Company name (max 500 characters)","example":"Acme Corp"},"websiteUrl":{"type":["string","null"],"minLength":1,"maxLength":2048,"description":"Company website URL. Stored as a CompanyIdentity of type `website`. Bare hostnames are normalised (`acme.com` → `https://acme.com`). At least one of `websiteUrl` or `linkedinUrl` is required.","example":"https://acme.com"},"linkedinUrl":{"type":["string","null"],"minLength":1,"maxLength":2048,"description":"LinkedIn company URL. Stored as a CompanyIdentity of type `linkedin`. At least one of `websiteUrl` or `linkedinUrl` is required.","example":"https://linkedin.com/company/acme"},"summary":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Short company summary. Empty strings are rejected — omit or send null to clear.","example":"B2B SaaS"},"overview":{"type":["string","null"],"minLength":1,"maxLength":16384,"description":"Long-form company description. Empty strings are rejected — omit or send null to clear."},"size":{"type":["string","null"],"enum":["1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+"],"description":"Company size bucket","example":"51-200"},"industry":{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":80},"maxItems":100,"description":"Industry labels. Duplicate entries are silently dropped (`[\"Tech\",\"Tech\"]` → `[\"Tech\"]`).","example":["Technology"]},"relationship":{"type":"string","enum":["client","target","none"],"default":"none","description":"Company relationship","example":"client"},"type":{"type":["string","null"],"enum":["educational","government","nonprofit","private","public"],"description":"Company type"},"employeeCount":{"type":["integer","null"],"minimum":1,"maximum":10000000,"description":"Number of employees (precise, integer)","example":150},"ticker":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Stock ticker symbol. Empty strings are rejected — omit or send null to clear.","example":"AAPL"},"location":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Display name / formatted address (e.g. \"London, UK\")","example":"London, UK"},"locality":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"City name","example":"London"},"region":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"State or region","example":"England"},"metro":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Metro area (People Data Labs convention, e.g. \"new york, new york\")","example":"new york, new york"},"country":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Country name","example":"United Kingdom"},"streetAddress":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 1","example":"123 Baker Street"},"addressLine2":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 2 (apartment, suite, unit, etc.)","example":"Suite 200"},"postalCode":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Postal / ZIP code","example":"NW1 6XE"},"raw":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Raw unstructured address text (used for geocoding)","example":"123 Baker Street, London NW1 6XE, United Kingdom"},"latitude":{"type":["number","null"],"minimum":-90,"maximum":90,"description":"Latitude coordinate (-90..90)","example":51.523767},"longitude":{"type":["number","null"],"minimum":-180,"maximum":180,"description":"Longitude coordinate (-180..180)","example":-0.158519}},"description":"Location"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"customAttributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID — must belong to the agency at the correct scope."},"optionId":{"type":["string","null"],"format":"uuid","description":"Required for `options`-type attributes — set to the chosen option's UUID. Mutually exclusive with `value`. For multi-select (`multipleValues: true`), repeat the entry once per chosen optionId."},"value":{"anyOf":[{"type":"string","minLength":1,"maxLength":16384},{"type":"number"},{"type":"null"}],"description":"Attribute value. Shape depends on the attribute `type`:\n- `text_line` / `text_block`: non-empty string (trimmed, max 16384 chars).\n- `integer` / `number_input`: JSON number, integer only, must fit Postgres int32 (-2147483648..2147483647).\n- `date`: string `YYYY-MM-DD`, calendar-validated.\n- `options`: do not send `value` — use `optionId` instead.\nMutually exclusive with `optionId`. Sending neither is rejected (422).","example":"Some text value"}},"required":["customAttributeId"],"description":"Custom attribute value"},"maxItems":100,"default":[],"description":"Custom attribute values. For options-type attributes with multipleValues=true, repeat the same customAttributeId with distinct optionIds."}},"required":["name"]}}}},"responses":{"201":{"description":"Company created","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Company name","example":"Acme Corp"},"websiteUrl":{"type":["string","null"],"description":"The company's primary website as a canonical `https://…` URL, derived from the primary `website` identity (or the first one if none is flagged primary). `null` when the company has no website identity. Accepted verbatim by `GET /companies/find-one?website=`.","example":"https://acme.com"},"linkedinUrl":{"type":["string","null"],"description":"The company's primary LinkedIn page as a canonical `https://…` URL, derived from the primary `linkedin` identity (or the first one if none is flagged primary). `null` when the company has no linkedin identity. Accepted verbatim by `GET /companies/find-one?linkedin_url=`.","example":"https://linkedin.com/company/acme"},"relationship":{"type":"string","enum":["client","target","none"],"description":"Company relationship","example":"client"},"type":{"type":["string","null"],"enum":["educational","government","nonprofit","private","public"],"description":"Company type"},"size":{"type":["string","null"],"enum":["1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+"],"description":"Company size"},"industry":{"type":["array","null"],"items":{"type":"string"},"description":"Array of industry labels","example":["Technology"]},"summary":{"type":["string","null"],"description":"Short company summary"},"overview":{"type":["string","null"],"description":"Long-form company description"},"employeeCount":{"type":["integer","null"],"description":"Number of employees","example":150},"ticker":{"type":["string","null"],"description":"Stock ticker symbol","example":"AAPL"},"location":{"type":["object","null"],"properties":{"formattedAddress":{"type":["string","null"],"description":"Provider-normalized \"city, region, country\" string","example":"San Francisco, California, United States"},"city":{"type":["string","null"],"description":"City","example":"San Francisco"},"region":{"type":["string","null"],"description":"State / province","example":"California"},"country":{"type":["string","null"],"description":"Country","example":"United States"},"metro":{"type":["string","null"],"description":"Metro area","example":"San Francisco Bay Area"},"streetAddress":{"type":["string","null"],"description":"Street address line 1"},"addressLine2":{"type":["string","null"],"description":"Street address line 2"},"postalCode":{"type":["string","null"],"description":"Postal / zip code"},"raw":{"type":["string","null"],"description":"Unstructured location string (e.g. provider-normalized address text)","example":"London, England, United Kingdom"},"latitude":{"type":["number","null"],"description":"Latitude in decimal degrees","example":37.7749},"longitude":{"type":["number","null"],"description":"Longitude in decimal degrees","example":-122.4194}},"required":["formattedAddress","city","region","country","metro","streetAddress","addressLine2","postalCode","raw","latitude","longitude"],"description":"Company primary location, or `null` when no usable location data is on file"},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Identity type","example":"linkedin"},"value":{"type":"string","description":"Normalised identity value, stored scheme-less (protocol and `www.` stripped) so duplicates collapse to one company. Use the top-level `websiteUrl` / `linkedinUrl` fields for ready-to-use `https://…` URLs.","example":"linkedin.com/company/acme"},"primary":{"type":"boolean","description":"Whether this is the primary identity of its type"}},"required":["type","value","primary"]},"description":"Company identities (linkedin, website, etc.)"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Custom attribute values"},"owners":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Atlas user id of the owner"},"name":{"type":"string","description":"Owner's full name","example":"Jane Smith"},"email":{"type":"string","description":"Owner's email address","example":"jane@agency.com"}},"required":["id","name","email"]},"description":"Atlas users who own this company relationship (its relationship managers). A company can have several owners; the array is empty when none are assigned. Read-only — owners are managed in the Atlas app."},"createdAt":{"type":"string","description":"ISO 8601 creation timestamp"},"updatedAt":{"type":"string","description":"ISO 8601 last modification timestamp"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live companies; populated for tombstones (only returned when `includeDeleted=true` on the list endpoint)"}},"required":["id","name","websiteUrl","linkedinUrl","relationship","type","size","industry","summary","overview","employeeCount","ticker","location","identities","customAttributes","owners","createdAt","updatedAt","deletedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/companies/{id}":{"get":{"summary":"Get company by ID","description":"Returns full company details including location, identities, custom attributes, and owners (the Atlas users managing the company relationship).","tags":["Companies"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Company ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Company found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Company name","example":"Acme Corp"},"websiteUrl":{"type":["string","null"],"description":"The company's primary website as a canonical `https://…` URL, derived from the primary `website` identity (or the first one if none is flagged primary). `null` when the company has no website identity. Accepted verbatim by `GET /companies/find-one?website=`.","example":"https://acme.com"},"linkedinUrl":{"type":["string","null"],"description":"The company's primary LinkedIn page as a canonical `https://…` URL, derived from the primary `linkedin` identity (or the first one if none is flagged primary). `null` when the company has no linkedin identity. Accepted verbatim by `GET /companies/find-one?linkedin_url=`.","example":"https://linkedin.com/company/acme"},"relationship":{"type":"string","enum":["client","target","none"],"description":"Company relationship","example":"client"},"type":{"type":["string","null"],"enum":["educational","government","nonprofit","private","public"],"description":"Company type"},"size":{"type":["string","null"],"enum":["1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+"],"description":"Company size"},"industry":{"type":["array","null"],"items":{"type":"string"},"description":"Array of industry labels","example":["Technology"]},"summary":{"type":["string","null"],"description":"Short company summary"},"overview":{"type":["string","null"],"description":"Long-form company description"},"logo":{"type":["string","null"],"description":"Logo image URL"},"employeeCount":{"type":["integer","null"],"description":"Number of employees","example":150},"ticker":{"type":["string","null"],"description":"Stock ticker symbol","example":"AAPL"},"location":{"type":["object","null"],"properties":{"formattedAddress":{"type":["string","null"],"description":"Provider-normalized \"city, region, country\" string","example":"San Francisco, California, United States"},"city":{"type":["string","null"],"description":"City","example":"San Francisco"},"region":{"type":["string","null"],"description":"State / province","example":"California"},"country":{"type":["string","null"],"description":"Country","example":"United States"},"metro":{"type":["string","null"],"description":"Metro area","example":"San Francisco Bay Area"},"streetAddress":{"type":["string","null"],"description":"Street address line 1"},"addressLine2":{"type":["string","null"],"description":"Street address line 2"},"postalCode":{"type":["string","null"],"description":"Postal / zip code"},"raw":{"type":["string","null"],"description":"Unstructured location string (e.g. provider-normalized address text)","example":"London, England, United Kingdom"},"latitude":{"type":["number","null"],"description":"Latitude in decimal degrees","example":37.7749},"longitude":{"type":["number","null"],"description":"Longitude in decimal degrees","example":-122.4194}},"required":["formattedAddress","city","region","country","metro","streetAddress","addressLine2","postalCode","raw","latitude","longitude"],"description":"Company primary location, or `null` when no usable location data is on file"},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Identity type","example":"linkedin"},"value":{"type":"string","description":"Normalised identity value, stored scheme-less (protocol and `www.` stripped) so duplicates collapse to one company. Use the top-level `websiteUrl` / `linkedinUrl` fields for ready-to-use `https://…` URLs.","example":"linkedin.com/company/acme"},"primary":{"type":"boolean","description":"Whether this is the primary identity of its type"}},"required":["type","value","primary"]},"description":"Company identities (linkedin, website, etc.)"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Custom attribute values"},"owners":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Atlas user id of the owner"},"name":{"type":"string","description":"Owner's full name","example":"Jane Smith"},"email":{"type":"string","description":"Owner's email address","example":"jane@agency.com"}},"required":["id","name","email"]},"description":"Atlas users who own this company relationship (its relationship managers). A company can have several owners; the array is empty when none are assigned. Read-only — owners are managed in the Atlas app."},"createdAt":{"type":"string","description":"ISO 8601 creation timestamp"},"updatedAt":{"type":"string","description":"ISO 8601 last modification timestamp"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live companies; populated for tombstones (only returned when `includeDeleted=true` on the list endpoint)"}},"required":["id","name","websiteUrl","linkedinUrl","relationship","type","size","industry","summary","overview","logo","employeeCount","ticker","location","identities","customAttributes","owners","createdAt","updatedAt","deletedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized"},"404":{"description":"Company not found"},"422":{"description":"Validation error"},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}},"patch":{"summary":"Update a company","description":"Use this endpoint to update an existing company in your Atlas account — refresh scalar fields, location, identities, and custom attributes (e.g. Tier, Employer Type, Headcount) without a manual CSV import.\n\nEvery field is optional — send only what you want to change. An omitted field is left untouched; sending `null` on a nullable field clears it. At least one updatable field must be present (otherwise 422).\n\n**Scalars & location.** `name`, `relationship`, `type`, `size`, `summary`, `overview`, `industry`, `employeeCount`, `ticker` and `location` overwrite the stored value. Changing `name` re-indexes the company for search and refreshes the cached company name on the people who work there.\n\n**Identities are replace-all.** When you send `identities`, the company’s existing identities are removed and the supplied set inserted. Omit the field to leave identities unchanged. URL-like values are normalised (protocol / `www.` stripped, lower-cased) before storage. An identity value already owned by a **different** company in your agency fails with **409 Conflict**.\n\n**Custom attributes are replaced per attribute.** For every `customAttributeId` you send, the company’s existing values for that attribute are removed and the supplied ones written. Attributes you don’t mention are left as-is.\n\n**Attribution.** `ownerEmail` is optional and records which Atlas user made the change; when supplied it must match a user in your agency (otherwise 404).\n\nReturns the full updated company — the same shape as `GET /api/v1/companies/{id}`.","tags":["Companies"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Company ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCompanyPayload"}}}},"responses":{"200":{"description":"Company updated. All changes are applied synchronously.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Company name","example":"Acme Corp"},"websiteUrl":{"type":["string","null"],"description":"The company's primary website as a canonical `https://…` URL, derived from the primary `website` identity (or the first one if none is flagged primary). `null` when the company has no website identity. Accepted verbatim by `GET /companies/find-one?website=`.","example":"https://acme.com"},"linkedinUrl":{"type":["string","null"],"description":"The company's primary LinkedIn page as a canonical `https://…` URL, derived from the primary `linkedin` identity (or the first one if none is flagged primary). `null` when the company has no linkedin identity. Accepted verbatim by `GET /companies/find-one?linkedin_url=`.","example":"https://linkedin.com/company/acme"},"relationship":{"type":"string","enum":["client","target","none"],"description":"Company relationship","example":"client"},"type":{"type":["string","null"],"enum":["educational","government","nonprofit","private","public"],"description":"Company type"},"size":{"type":["string","null"],"enum":["1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+"],"description":"Company size"},"industry":{"type":["array","null"],"items":{"type":"string"},"description":"Array of industry labels","example":["Technology"]},"summary":{"type":["string","null"],"description":"Short company summary"},"overview":{"type":["string","null"],"description":"Long-form company description"},"logo":{"type":["string","null"],"description":"Logo image URL"},"employeeCount":{"type":["integer","null"],"description":"Number of employees","example":150},"ticker":{"type":["string","null"],"description":"Stock ticker symbol","example":"AAPL"},"location":{"type":["object","null"],"properties":{"formattedAddress":{"type":["string","null"],"description":"Provider-normalized \"city, region, country\" string","example":"San Francisco, California, United States"},"city":{"type":["string","null"],"description":"City","example":"San Francisco"},"region":{"type":["string","null"],"description":"State / province","example":"California"},"country":{"type":["string","null"],"description":"Country","example":"United States"},"metro":{"type":["string","null"],"description":"Metro area","example":"San Francisco Bay Area"},"streetAddress":{"type":["string","null"],"description":"Street address line 1"},"addressLine2":{"type":["string","null"],"description":"Street address line 2"},"postalCode":{"type":["string","null"],"description":"Postal / zip code"},"raw":{"type":["string","null"],"description":"Unstructured location string (e.g. provider-normalized address text)","example":"London, England, United Kingdom"},"latitude":{"type":["number","null"],"description":"Latitude in decimal degrees","example":37.7749},"longitude":{"type":["number","null"],"description":"Longitude in decimal degrees","example":-122.4194}},"required":["formattedAddress","city","region","country","metro","streetAddress","addressLine2","postalCode","raw","latitude","longitude"],"description":"Company primary location, or `null` when no usable location data is on file"},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Identity type","example":"linkedin"},"value":{"type":"string","description":"Normalised identity value, stored scheme-less (protocol and `www.` stripped) so duplicates collapse to one company. Use the top-level `websiteUrl` / `linkedinUrl` fields for ready-to-use `https://…` URLs.","example":"linkedin.com/company/acme"},"primary":{"type":"boolean","description":"Whether this is the primary identity of its type"}},"required":["type","value","primary"]},"description":"Company identities (linkedin, website, etc.)"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Custom attribute values"},"owners":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Atlas user id of the owner"},"name":{"type":"string","description":"Owner's full name","example":"Jane Smith"},"email":{"type":"string","description":"Owner's email address","example":"jane@agency.com"}},"required":["id","name","email"]},"description":"Atlas users who own this company relationship (its relationship managers). A company can have several owners; the array is empty when none are assigned. Read-only — owners are managed in the Atlas app."},"createdAt":{"type":"string","description":"ISO 8601 creation timestamp"},"updatedAt":{"type":"string","description":"ISO 8601 last modification timestamp"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live companies; populated for tombstones (only returned when `includeDeleted=true` on the list endpoint)"}},"required":["id","name","websiteUrl","linkedinUrl","relationship","type","size","industry","summary","overview","logo","employeeCount","ticker","location","identities","customAttributes","owners","createdAt","updatedAt","deletedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Company not found, the provided ownerEmail matches no user, or a referenced customAttribute / option does not exist.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/companies/find-one":{"get":{"summary":"Find a single company by identifier","description":"Finds a single company in your agency matching one of the provided identifiers. At least one of `name`, `linkedin_url`, `website`, or `identity` must be provided. If multiple are provided they are evaluated as OR in this precedence order — `linkedin_url` → `website` → `identity` → `name` — and the first match wins. The `name` lookup matches case-insensitively with fuzzy tolerance: exact matches win, then case-insensitive substring, then typo-tolerant (trigram) similarity, and the single best-scoring company is returned. `linkedin_url` and `website` accept either a full `https://…` URL or the scheme-less value returned in a company’s `identities` / `linkedinUrl` / `websiteUrl` (e.g. `acme.com`), so a value read from `GET /companies/{id}` can be passed straight back. Returns full company details including location, identities, custom attributes, and owners.","tags":["Companies"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":500,"description":"Company name. Matched case-insensitively with typo-tolerant full-text ranking — the single best-scoring company in the caller's agency is returned.\n\nNote: name matching uses an asynchronous search index that is eventually consistent with the primary store. A company created via `POST /companies` may take a short interval (typically sub-second, occasionally longer under load) to become discoverable via `?name=`. Clients that need read-your-writes semantics should look the company up by `linkedin_url`, `website`, or the id returned from `POST /companies` instead.","example":"Acme Corp"},"required":false,"name":"name","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":2048,"description":"LinkedIn company URL. Bare hostnames / scheme-less slugs are accepted and normalised (`linkedin.com/company/acme` → `https://linkedin.com/company/acme`), so the value returned in a company’s `linkedin` identity can be passed back verbatim.","example":"https://linkedin.com/company/acme"},"required":false,"name":"linkedin_url","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":2048,"description":"Company website URL. Bare hostnames are accepted and normalised (`acme.com` → `https://acme.com`), so the value returned in a company’s `website` identity can be passed back verbatim.","example":"https://acme.com"},"required":false,"name":"website","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":2048,"description":"Any identity value — searches across all company identity types","example":"https://github.com/acme"},"required":false,"name":"identity","in":"query"}],"responses":{"200":{"description":"Lookup succeeded. `data` is the matched company, or `null` when no company matched the supplied identifiers.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Company name","example":"Acme Corp"},"websiteUrl":{"type":["string","null"],"description":"The company's primary website as a canonical `https://…` URL, derived from the primary `website` identity (or the first one if none is flagged primary). `null` when the company has no website identity. Accepted verbatim by `GET /companies/find-one?website=`.","example":"https://acme.com"},"linkedinUrl":{"type":["string","null"],"description":"The company's primary LinkedIn page as a canonical `https://…` URL, derived from the primary `linkedin` identity (or the first one if none is flagged primary). `null` when the company has no linkedin identity. Accepted verbatim by `GET /companies/find-one?linkedin_url=`.","example":"https://linkedin.com/company/acme"},"relationship":{"type":"string","enum":["client","target","none"],"description":"Company relationship","example":"client"},"type":{"type":["string","null"],"enum":["educational","government","nonprofit","private","public"],"description":"Company type"},"size":{"type":["string","null"],"enum":["1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+"],"description":"Company size"},"industry":{"type":["array","null"],"items":{"type":"string"},"description":"Array of industry labels","example":["Technology"]},"summary":{"type":["string","null"],"description":"Short company summary"},"overview":{"type":["string","null"],"description":"Long-form company description"},"logo":{"type":["string","null"],"description":"Logo image URL"},"employeeCount":{"type":["integer","null"],"description":"Number of employees","example":150},"ticker":{"type":["string","null"],"description":"Stock ticker symbol","example":"AAPL"},"location":{"type":["object","null"],"properties":{"formattedAddress":{"type":["string","null"],"description":"Provider-normalized \"city, region, country\" string","example":"San Francisco, California, United States"},"city":{"type":["string","null"],"description":"City","example":"San Francisco"},"region":{"type":["string","null"],"description":"State / province","example":"California"},"country":{"type":["string","null"],"description":"Country","example":"United States"},"metro":{"type":["string","null"],"description":"Metro area","example":"San Francisco Bay Area"},"streetAddress":{"type":["string","null"],"description":"Street address line 1"},"addressLine2":{"type":["string","null"],"description":"Street address line 2"},"postalCode":{"type":["string","null"],"description":"Postal / zip code"},"raw":{"type":["string","null"],"description":"Unstructured location string (e.g. provider-normalized address text)","example":"London, England, United Kingdom"},"latitude":{"type":["number","null"],"description":"Latitude in decimal degrees","example":37.7749},"longitude":{"type":["number","null"],"description":"Longitude in decimal degrees","example":-122.4194}},"required":["formattedAddress","city","region","country","metro","streetAddress","addressLine2","postalCode","raw","latitude","longitude"],"description":"Company primary location, or `null` when no usable location data is on file"},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Identity type","example":"linkedin"},"value":{"type":"string","description":"Normalised identity value, stored scheme-less (protocol and `www.` stripped) so duplicates collapse to one company. Use the top-level `websiteUrl` / `linkedinUrl` fields for ready-to-use `https://…` URLs.","example":"linkedin.com/company/acme"},"primary":{"type":"boolean","description":"Whether this is the primary identity of its type"}},"required":["type","value","primary"]},"description":"Company identities (linkedin, website, etc.)"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Custom attribute values"},"owners":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Atlas user id of the owner"},"name":{"type":"string","description":"Owner's full name","example":"Jane Smith"},"email":{"type":"string","description":"Owner's email address","example":"jane@agency.com"}},"required":["id","name","email"]},"description":"Atlas users who own this company relationship (its relationship managers). A company can have several owners; the array is empty when none are assigned. Read-only — owners are managed in the Atlas app."},"createdAt":{"type":"string","description":"ISO 8601 creation timestamp"},"updatedAt":{"type":"string","description":"ISO 8601 last modification timestamp"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live companies; populated for tombstones (only returned when `includeDeleted=true` on the list endpoint)"}},"required":["id","name","websiteUrl","linkedinUrl","relationship","type","size","industry","summary","overview","logo","employeeCount","ticker","location","identities","customAttributes","owners","createdAt","updatedAt","deletedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/contacts":{"get":{"summary":"List company contacts","description":"Returns a paginated list of company contacts under the caller's agency, newest first. A contact is the link between a person and a company (`company_contacts`); each person has at most one live contact.\n\n**Filters:**\n- `relationship` — comma-separated relationships (`client`, `prospect`, `none`). Every row is returned by default, including `none` links.\n- `personId` — return only the contact for a single person.\n- `companyId` — return only contacts linked to a single company.\n- `createdAfter` / `createdBefore` — only contacts created within the range (inclusive).\n- `updatedAfter` / `updatedBefore` — only contacts updated within the range (inclusive). Use `updatedAfter` as an incremental-sync cursor: persist the maximum `updatedAt` you receive and pass it back on the next poll to fetch only what changed (upsert by `id` to stay idempotent).\n\n**Deletions (tombstones):**\nSoft-deleted contacts are excluded by default. Pass `includeDeleted=true` to also receive deleted contacts as tombstones — each carries a populated `deletedAt` (live rows have `deletedAt: null`). Combine `includeDeleted=true` with `updatedAfter` to incrementally pick up deletions: a soft-delete bumps `updatedAt`, so the deleted row resurfaces in the next poll with `deletedAt` set.","tags":["Contacts"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Comma-separated company-contact relationships to filter by","example":"client,prospect"},"required":false,"name":"relationship","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter to the contact row for a single person","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter to contacts linked to a single company","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"companyId","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only contacts created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only contacts created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only contacts updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only contacts updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted contacts as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"string","enum":["asc","desc"],"default":"desc","description":"Sort order by `createdAt` (record creation time) — `desc` (default, newest first) or `asc` (oldest first). `id` is used as a stable tie-breaker, applied in the same direction.","example":"desc"},"required":false,"name":"order","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of company contacts","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"personId":{"type":"string","format":"uuid","description":"ID of the linked person"},"companyId":{"type":"string","format":"uuid","description":"ID of the linked company"},"relationship":{"type":"string","enum":["client","prospect","none"],"description":"Contact relationship. `none` means the person is linked to a company but not (yet) an active client/prospect contact.","example":"client"},"title":{"type":["string","null"],"description":"Contact job title at the company","example":"Head of Talent"},"seniority":{"type":["string","null"],"enum":["Partner","Board","Founder","CXO","VP","Director","Manager","Senior","Middle","Junior","Training","Unpaid"],"description":"Derived work seniority"},"person":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"],"description":"Person first name","example":"Michael"},"lastName":{"type":["string","null"],"description":"Person last name","example":"Scott"}},"required":["id","firstName","lastName"],"description":"Light reference to the linked person, or `null` when it could not be loaded"},"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Company name","example":"Acme Corp"}},"required":["id","name"],"description":"Light reference to the linked company, or `null` when it could not be loaded"},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"User full name","example":"Jane Recruiter"},"email":{"type":["string","null"],"description":"User email","example":"jane@agency.com"}},"required":["id","name","email"],"description":"The user who created the contact. For contacts predating July 2026 this is backfilled from the creator of the linked person (best-effort), and `null` where no creator could be resolved."},"createdAt":{"type":"string","description":"ISO 8601 creation timestamp"},"updatedAt":{"type":"string","description":"ISO 8601 last modification timestamp"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live contacts; populated for tombstones (only returned when `includeDeleted=true`)"}},"required":["id","personId","companyId","relationship","title","seniority","person","company","createdBy","createdAt","updatedAt","deletedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":42},"totalPages":{"type":"integer","description":"Total number of pages","example":2}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people":{"get":{"summary":"List people","description":"Use this endpoint to retrieve a paginated list of people (contacts and candidates) across your **entire** agency.\n\nIt is the counterpart to **Get a person** (`GET /api/v1/people/{id}`) for bulk reads: sync every person into an external system, or count how many people each consultant added to the CRM over a period.\n\n**What you can filter by:**\n- `createdByIds` — comma-separated Atlas user UUIDs; return only people added by those consultants (matches `Person.createdById`). This is the filter to use for \"people/candidates added per consultant\".\n- `createdAfter` / `createdBefore` — only people added within a date range (inclusive). Accepts an ISO 8601 datetime or a date-only `YYYY-MM-DD` value.\n- `updatedAfter` / `updatedBefore` — only people last modified within a date range (inclusive). Use `updatedAfter` as an incremental-sync cursor.\n- `includeDeleted` — when `true`, soft-deleted people are included as tombstones (with a populated `deletedAt`) so you can sync deletions.\n\n**Ordering:**\nResults are ordered by creation date (`createdAt`, then `id` as a tiebreaker). Use `sortDirection=asc` or `sortDirection=desc` (default `desc`, newest first).\n\n**Pagination:**\nUse `page` (1-based) and `pageSize` (1–100, default 25). The response includes a `pagination` object with `page`, `pageSize`, `total`, and `totalPages`.\n\n**What you get back:**\nEach person includes `id`, `firstName`, `lastName`, headline (`headlineRole`, `headlineCompanyName`), `relationshipType` (`contact` vs `candidate`), the `company` they are a contact at (when applicable), the `createdBy` consultant, and `createdAt` / `updatedAt` / `deletedAt` timestamps. Use `id` with **Get a person** to fetch full details.","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Filter to people created by these Atlas users (the consultant who added them). Comma-separated user UUIDs — matches `Person.createdById`. Omit to return people added by anyone.","example":"550e8400-e29b-41d4-a716-446655440000,660e8400-e29b-41d4-a716-446655440001"},"required":false,"name":"createdByIds","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only people created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only people created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only people updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only people updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted people as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"string","enum":["asc","desc"],"default":"desc","description":"Sort direction by creation date (createdAt, then id as a tiebreaker). Defaults to desc (newest first).","example":"desc"},"required":false,"name":"sortDirection","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of people","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Person ID (people.id)"},"firstName":{"type":["string","null"],"description":"Person first name"},"lastName":{"type":["string","null"],"description":"Person last name"},"headlineRole":{"type":["string","null"],"description":"Current/most recent job title","example":"Regional Manager"},"headlineCompanyName":{"type":["string","null"],"description":"Current/most recent company name","example":"Dunder Mifflin"},"relationshipType":{"type":"string","enum":["contact","candidate"],"description":"Simplified split derived **solely** from company-contact links: `contact` when the person has at least one live company-contact link, otherwise `candidate`. Important: `candidate` here means only \"not a client contact\" — it does **NOT** indicate the person is (or ever was) a candidate linked to any project. A person who is both a client contact and an active candidate is reported as `contact`, and the candidacy is not reflected in this field. To count real candidates or pipeline candidacies, use `GET /api/v1/candidates` — not this field.","example":"candidate"},"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company ID"},"name":{"type":"string","description":"Company name"}},"required":["id","name"],"description":"The company this person is a contact at (only when `relationshipType` is `contact`). May reference a company that is excluded from the default `GET /api/v1/companies` list (which returns only `client`/`target` by default) — resolve it via `GET /api/v1/companies/{id}`."},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Creating user ID"},"name":{"type":["string","null"],"description":"Creating user name"},"email":{"type":["string","null"],"description":"Creating user email"}},"required":["id","name","email"],"description":"The Atlas user (consultant) who added this person to the CRM"},"createdAt":{"type":["string","null"],"description":"ISO 8601 — when the person was added"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 — when the person was last modified"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 — when the person was soft-deleted, null unless returned as a tombstone"}},"required":["id","firstName","lastName","headlineRole","headlineCompanyName","relationshipType","company","createdBy","createdAt","updatedAt","deletedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Results per page","example":25},"total":{"type":"integer","description":"Total number of matching people","example":42},"totalPages":{"type":"integer","description":"Total number of pages","example":2}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Create a person","description":"Use this endpoint to add a new person to your Atlas account.\n\nThis is the main endpoint for pushing contact data into Atlas from an external system - for example, from your website's application form, a spreadsheet import, or another tool in your tech stack.\n\n**Strict create — never updates an existing person.**\n\nIf any of the contact details (identities) you provide already point at a person in your agency, the request fails with **409 Conflict** and the response body includes the existing `personId`. To update an existing record, use **`PATCH /api/v1/people/{id}`** (fields, identities, custom attributes) and the dedicated experience / education endpoints — this endpoint will not merge data into an existing record under any circumstances. The typical integration flow is: POST, and on a 409 take the returned `personId` and PATCH it.\n\n**Conflict detection order:**\n\n1. `source.externalId` paired with `source.system` (high-confidence external-id match).\n2. Email address (normalized).\n3. LinkedIn URL (`linkedin`, `linkedin_salesnav`, `linkedin_recruiter`).\n4. Phone number (matched against all variations Atlas stores).\n\nThe first match wins; the returned `personId` is the conflicting person's id, and `conflictType` tells you which rule matched — `\"externalId\"` for rule 1, `\"identity\"` for rules 2–4 (each with a matching distinct `error` message).\n\n**Important fields:**\n- `identities` - you must provide at least one non-website contact detail (email, phone, or LinkedIn). This is how Atlas identifies the person and how the conflict check runs.\n- `addedByEmail` - the email address of an existing Atlas user in your agency. This is the team member the action will be attributed to. If no user exists with this email, the request fails with a 404.\n- `source` - optional. Tag where this data came from (e.g. `\"website\"`, `\"zapier\"`, `\"hubspot\"`).\n\n**`source.externalId` uniqueness.** A `(source.system, source.externalId)` pair is unique within your agency — it identifies at most one person and is the highest-priority conflict rule (rule 1 above). Re-pushing a record whose externalId is already stored on a person returns 409 with that person's `personId`, so it doubles as an idempotency key: on 409, switch to `PATCH /api/v1/people/{id}`.\n\n**Synchronous vs. asynchronous creation.**\n\nThe person record, its identities, headline, and any custom attribute values are created **synchronously** — once you receive the 201, the person exists and the conflict check above will match it.\n\nWork experience, education and compensation are created **asynchronously**: each is queued as an independent background job. They are validated synchronously (an invalid payload still returns 422 before anything is created), so anything accepted will be created — but it may take a short moment to appear, so they are **not guaranteed to be present on an immediate `GET /api/v1/people/{id}`**. The company-contact link (when `isContact: true`) is created **synchronously** and is present on that immediate read. A failure creating one of these extras (e.g. a transient database error) is retried automatically and never affects the person, the other extras, or the 201 you already received.\n\n**What you get back:**\n\nOn success (201), the response includes:\n- The Atlas person ID\n- `created: true`\n- `identitiesCreated` / `customAttributesCreated` — counts created synchronously\n- `queued` — counts of experience, education, compensation, and company-contact records accepted and enqueued for asynchronous creation","tags":["People"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePersonPayload"}}}},"responses":{"201":{"description":"New person created. Identities, custom attributes and the company-contact link (when `isContact` is set) are created synchronously; experience, education and compensation are enqueued for asynchronous creation (see the `queued` counts).","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"personId":{"type":"string","format":"uuid"},"created":{"type":"boolean","enum":[true]},"identitiesCreated":{"type":"integer","description":"Identities created synchronously"},"customAttributesCreated":{"type":"integer","description":"Custom attribute values created synchronously"},"queued":{"type":"object","properties":{"experiences":{"type":"integer"},"educations":{"type":"integer"},"compensation":{"type":"integer"},"companyContact":{"type":"integer"}},"required":["experiences","educations","compensation","companyContact"],"description":"Counts of extra entities. `experiences`, `educations` and `compensation` are enqueued for asynchronous creation (not yet present when this response is returned). `companyContact` is created synchronously — `1` when the link was created, `0` otherwise."}},"required":["personId","created","identitiesCreated","customAttributesCreated","queued"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"User not found for the provided addedByEmail, OR a referenced customAttribute / option does not exist in the agency.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"A person with one of the supplied identities — or the same (source.system, source.externalId) pair — already exists in this agency. `conflictType` says which rule matched (`identity` vs `externalId`) and the `error` message differs accordingly. This endpoint never updates: take the returned personId and use PATCH /api/v1/people/{id} to update that person.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable conflict explanation. Distinct per rule: identity conflicts return \"A person with one of the supplied identities already exists\"; (source.system, source.externalId) conflicts return \"A person with the same (source.system, source.externalId) pair already exists\".","example":"A person with one of the supplied identities already exists"},"conflictType":{"type":"string","enum":["identity","externalId"],"description":"Which uniqueness rule matched: `externalId` — the (source.system, source.externalId) pair already belongs to a person; `identity` — an email / LinkedIn URL / phone number already belongs to a person. When both would match, `externalId` wins (it is checked first).","example":"identity"},"personId":{"type":"string","format":"uuid","description":"ID of the existing person that conflicted. Use it with GET /api/v1/people/{id} or PATCH /api/v1/people/{id}."}},"required":["status","error","conflictType","personId"]}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/lookup":{"post":{"summary":"Bulk lookup people by identity","description":"Use this endpoint to check whether a batch of contacts already exist in your Atlas account - before importing or syncing them from another system.\n\nYou send a list of contact details (up to 100 at a time), and Atlas will automatically figure out what type each one is based on its format - for example, anything that looks like an email address will be treated as an email, a URL containing \"linkedin.com\" will be treated as a LinkedIn profile, and a string of digits will be treated as a phone number.\n\nFor each item you send, Atlas tells you:\n- Whether it recognised the format (if not, it returns `\"invalid identity format\"`)\n- Whether a person with that contact detail already exists in your agency\n- The ID of that person, if they exist (useful for linking records)\n\nThis is particularly useful when you have a spreadsheet or external system full of contacts and you want to quickly find out which ones are already in Atlas before deciding what to do with the rest.","tags":["People"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LookupPeoplePayload"}}}},"responses":{"200":{"description":"Lookup results, one entry per input identity in the same order","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Auto-detected identity type, or \"invalid identity format\""},"value":{"type":"string","description":"Original input value"},"exists":{"type":"boolean"},"personId":{"type":["string","null"],"format":"uuid"}},"required":["type","value","exists","personId"]}}},"required":["status","data"]},"example":{"status":"ok","data":[{"type":"email","value":"test@example.com","exists":true,"personId":"123e4567-e89b-12d3-a456-426614174000"},{"type":"linkedin","value":"https://www.linkedin.com/in/johndoe","exists":true,"personId":"987e6543-e21b-12d3-a456-426614174000"},{"type":"email","value":"unknown@example.com","exists":false,"personId":null}]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/search":{"get":{"summary":"Search people by identity","description":"Use this endpoint to look up a specific person in your Atlas account using a contact detail you already know - such as their email address, phone number, or LinkedIn profile URL.\n\nFor example, if someone fills out a form on your website and you have their email address, you can use this endpoint to instantly check whether they already exist in Atlas and retrieve their full record.\n\nYou must provide at least one of: email, phone, or LinkedIn URL. Atlas will search your agency's data and return any matching people along with their name, current role, current company, and all known contact details (identities) on file.\n\nIf no match is found, an empty list is returned - it will not throw an error.","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Comma-separated email addresses","example":"alice@example.com,bob@example.com"},"required":false,"name":"emails","in":"query"},{"schema":{"type":"string","description":"Comma-separated phone numbers","example":"+14155551234,+442071234567"},"required":false,"name":"phones","in":"query"},{"schema":{"type":"string","description":"Comma-separated LinkedIn URLs","example":"linkedin.com/in/john-doe,linkedin.com/in/jane-smith"},"required":false,"name":"linkedinUrls","in":"query"}],"responses":{"200":{"description":"Matching people with their identities","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"],"description":"First name","example":"John"},"lastName":{"type":["string","null"],"description":"Last name","example":"Doe"},"headlineRole":{"type":["string","null"],"description":"Current role/job title","example":"Software Engineer"},"headlineCompanyName":{"type":["string","null"],"description":"Current company name","example":"Acme Corp"},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"value":{"type":"string"},"active":{"type":"boolean"}},"required":["type","value","active"]}}},"required":["id","firstName","lastName","headlineRole","headlineCompanyName","identities"]}}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/{id}":{"get":{"summary":"Get a person by ID","description":"Use this endpoint to retrieve the full details of a single person from your Atlas account by their ID.\n\nThis is the endpoint to use when you already know which person you want - for example, after finding them via the Search endpoint, receiving their ID from a webhook, or storing it from a previous Create call. It returns everything Atlas knows about the person in one response.\n\n**What you need to provide:**\nJust the person's ID in the URL path (e.g. `/api/v1/people/abc-123`). Person IDs are UUIDs returned by other endpoints like Search, Lookup, or Create.\n\n**What you get back:**\nThe person's full profile - basic info, identities, headline, address, work experience, education, compensation, custom attributes, source, and metadata.\n\n**Merged people:** If the requested person was merged into another person in Atlas, this endpoint transparently returns the surviving person's data. The response includes a `merged` field indicating the original ID that was looked up and the resolved ID being returned, so the caller can update any cached references.\n\nIf no person exists with the given ID in your agency, the endpoint returns a 404 error.","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Atlas person ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Person details","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Atlas person ID (the resolved ID, after merge resolution)"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"middleName":{"type":["string","null"]},"isContact":{"type":"boolean","description":"Whether this person is a company contact in any company"},"companyContact":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"The CompanyContact junction ID — the link between this person and the company. This is the value to pass in `companyContactIds` when associating the person with a job_lead via POST /api/v1/projects. It is NOT the person ID; passing a person ID there returns 404.","example":"b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"},"companyId":{"type":"string","format":"uuid","description":"The company this person is a contact at"},"relationship":{"type":"string","enum":["client","prospect","none"],"description":"The contact's relationship to the company","example":"prospect"},"title":{"type":["string","null"],"description":"Job title at the company","example":"VP of Engineering"}},"required":["id","companyId","relationship","title"],"description":"The active company-contact link for this person, or null if they are not a contact at any company. A person has at most one. Use its `id` as the `companyContactIds` value when linking this person to a job_lead via POST /api/v1/projects."},"gender":{"type":["string","null"],"enum":["male","female"]},"source":{"type":["object","null"],"properties":{"system":{"type":["string","null"],"description":"Source system that last touched this person","example":"api"},"externalId":{"type":["string","null"],"description":"External ID from the source system","example":"124456"}},"required":["system","externalId"],"description":"Source system metadata, or null if the person has no source recorded"},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Identity type","example":"email"},"value":{"type":"string","description":"Identity value","example":"mscott@example.com"},"isPersonal":{"type":"boolean","description":"Whether this identity is a personal contact detail","example":true},"isPrimary":{"type":"boolean","description":"Whether this is the primary/favourite identity","example":true}},"required":["type","value","isPersonal","isPrimary"]}},"headline":{"type":["object","null"],"properties":{"role":{"type":["string","null"],"description":"Current role/job title","example":"Regional Manager"},"company":{"type":["string","null"],"description":"Current company name","example":"Dunder Mifflin"},"companyId":{"type":["string","null"],"format":"uuid","description":"Atlas company ID for the current company"},"roleStartedAt":{"type":["string","null"],"description":"When the current role started (YYYY-MM-DD)","example":"2023-04-01"}},"required":["role","company","companyId","roleStartedAt"],"description":"Current role and company, or null if the person has no current role"},"address":{"type":["object","null"],"properties":{"raw":{"type":["string","null"],"description":"Full address string"},"streetAddress":{"type":["string","null"],"description":"Street address"},"addressLine2":{"type":["string","null"],"description":"Address line 2 (apt, suite, etc.)"},"city":{"type":["string","null"],"description":"City (locality)"},"region":{"type":["string","null"],"description":"Region / state"},"postalCode":{"type":["string","null"],"description":"Postal/ZIP code"},"country":{"type":["string","null"],"description":"Country"},"metro":{"type":["string","null"],"description":"Metro area (US only)"},"formattedAddress":{"type":["string","null"],"description":"Cleaned \"locality, region, country\" string"},"latitude":{"type":["number","null"],"description":"Latitude coordinate"},"longitude":{"type":["number","null"],"description":"Longitude coordinate"}},"required":["raw","streetAddress","addressLine2","city","region","postalCode","country","metro","formattedAddress","latitude","longitude"]},"experience":{"type":"array","items":{"type":"object","properties":{"companyName":{"type":"string","description":"Company name","example":"Dunder Mifflin"},"companyId":{"type":["string","null"],"format":"uuid","description":"Linked Atlas company ID","example":"550e8400-e29b-41d4-a716-446655440000"},"companyLinkedinId":{"type":["string","null"],"description":"Company LinkedIn ID","example":"12345678"},"companyDomain":{"type":["string","null"],"description":"Company website domain","example":"dundermifflin.com"},"role":{"type":"string","description":"Job title/role","example":"Regional Manager"},"description":{"type":["string","null"],"description":"Role description as plain text","example":"Led day-to-day operations for a 3-member team. Developed and maintained dashboards in Power BI."},"startDate":{"type":["string","null"],"description":"Start date (YYYY-MM-DD)","example":"2021-04-01"},"endDate":{"type":["string","null"],"description":"End date (YYYY-MM-DD, null = current)","example":null}},"required":["companyName","companyId","companyLinkedinId","companyDomain","role","description","startDate","endDate"]}},"education":{"type":"array","items":{"type":"object","properties":{"institutionName":{"type":"string","description":"Institution name","example":"University of Scranton"},"degree":{"type":["string","null"],"description":"Degree name","example":"Bachelor of Science"},"fieldOfStudy":{"type":["string","null"],"description":"Field of study","example":"Business Administration"},"grade":{"type":["string","null"],"description":"Grade/GPA","example":"3.4 GPA"},"description":{"type":["string","null"],"description":"Description"},"startDate":{"type":["string","null"],"description":"Start date","example":"2012-09-01"},"endDate":{"type":["string","null"],"description":"End date","example":"2016-06-30"}},"required":["institutionName","degree","fieldOfStudy","grade","description","startDate","endDate"]}},"compensation":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["actual","expected"],"description":"Compensation type","example":"actual"},"taxMethod":{"type":["string","null"],"description":"gross / net / hourly_rate / day_rate","example":"gross"},"currency":{"type":["string","null"],"description":"Currency code","example":"USD"},"relevantDate":{"type":["string","null"],"description":"Date this compensation is relevant to (YYYY-MM-DD)","example":"2024-06-01"},"basicSalary":{"type":["number","null"],"description":"Base salary amount","example":120000},"bonusSalary":{"type":["number","null"],"description":"Bonus salary amount","example":30000},"totalSalary":{"type":["number","null"],"description":"Total salary amount (typically base + bonus)","example":150000},"expectedSalaryMin":{"type":["number","null"],"description":"Expected base salary minimum","example":130000},"expectedSalaryMax":{"type":["number","null"],"description":"Expected base salary maximum","example":160000},"expectedBonusSalaryMin":{"type":["number","null"],"description":"Expected bonus salary minimum","example":10000},"expectedBonusSalaryMax":{"type":["number","null"],"description":"Expected bonus salary maximum","example":50000}},"required":["type","taxMethod","currency","relevantDate","basicSalary","bonusSalary","totalSalary","expectedSalaryMin","expectedSalaryMax","expectedBonusSalaryMin","expectedBonusSalaryMax"]}},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name","example":"Priority"},"attributeType":{"type":["string","null"],"description":"Attribute type (text_line, options, number, date, etc.)","example":"options"},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Selected option label","example":"Yes"}},"required":["optionId","optionValue"]}]},"description":"Values for this attribute. For \"options\" attributes each entry is { optionId, optionValue } and the array supports multi-select. For text/number/date attributes each entry is the typed value."}},"required":["attributeId","attributeName","attributeType","values"]}},"enrichmentStatus":{"type":"string","enum":["none","in_progress","failed","completed"],"description":"State of the most recent enrichment: `none` (never enriched), `in_progress`, `completed` or `failed`. Poll this after POST /api/v1/people/{id}/enrich.","example":"none"},"createdAt":{"type":"string","description":"When this person was created"},"updatedAt":{"type":"string","description":"When this person was last updated"},"merged":{"type":["object","null"],"properties":{"fromId":{"type":"string","format":"uuid","description":"The originally requested ID that was merged"},"toId":{"type":"string","format":"uuid","description":"The surviving (target) person ID returned in this response"}},"required":["fromId","toId"],"description":"Set when the requested ID was merged into another person. The response data is for the surviving person (toId)."}},"required":["id","firstName","lastName","middleName","isContact","companyContact","gender","source","identities","headline","address","experience","education","compensation","customAttributes","enrichmentStatus","createdAt","updatedAt","merged"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Person not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"patch":{"summary":"Update a person","description":"Use this endpoint to update an existing person in your Atlas account.\n\nThis endpoint changes **only the parts of a person that are applied immediately** — fields on the person record (name, gender, headline, location, source), their contact details (identities), and their custom attribute values. Work experience, education, compensation and the company-contact flag are **not** handled here — set them on `POST /api/v1/people` (experience, education and compensation are created via background jobs there; the company-contact link is created synchronously).\n\nEvery field is optional — send only what you want to change. An omitted field is left untouched; sending `null` on a nullable field clears it. At least one updatable field must be present.\n\n**Identities are additive.** Any identities you send are *added* to the person — existing ones are never removed. An identity already on this person is ignored; an identity that belongs to a **different** person fails with **409 Conflict** (`conflictType: \"identity\"`) and returns that person's `personId`. Updating identities requires `addedByEmail` (the acting user).\n\n**`source.externalId` uniqueness.** A `(source.system, source.externalId)` pair is unique within your agency. Setting `source` to a pair that already belongs to a **different** person fails with **409 Conflict** (`conflictType: \"externalId\"`) and returns that person's `personId` — even when no identities are supplied. Re-sending the pair already stored on the person being updated is fine.\n\n**Custom attributes are replaced per attribute.** For every `customAttributeId` you send, the person's existing values for that attribute are removed and the supplied ones written. Attributes you don't mention are left as-is. (Clearing an attribute by sending it empty is not supported here.)\n\n**Attribution.** `addedByEmail` is optional and records which Atlas user made the change; if omitted the update is unattributed (but is required when changing identities).","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Atlas person ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePersonPayload"}}}},"responses":{"200":{"description":"Person updated. All changes are applied synchronously.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"personId":{"type":"string","format":"uuid"},"identitiesAdded":{"type":"integer","description":"New identities added synchronously"},"customAttributesReplaced":{"type":"integer","description":"Custom attribute value rows written (after per-attribute replace)"}},"required":["personId","identitiesAdded","customAttributesReplaced"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Person not found, the provided addedByEmail matches no user, or a referenced customAttribute / option does not exist.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"One of the supplied identities — or the supplied (source.system, source.externalId) pair — already belongs to a **different** person in this agency. `conflictType` says which rule matched (`identity` vs `externalId`) and the `error` message differs accordingly; `personId` is the conflicting person. Values already on the person being updated never conflict.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable conflict explanation. Distinct per rule: identity conflicts return \"A person with one of the supplied identities already exists\"; (source.system, source.externalId) conflicts return \"A person with the same (source.system, source.externalId) pair already exists\".","example":"A person with one of the supplied identities already exists"},"conflictType":{"type":"string","enum":["identity","externalId"],"description":"Which uniqueness rule matched: `externalId` — the (source.system, source.externalId) pair already belongs to a person; `identity` — an email / LinkedIn URL / phone number already belongs to a person. When both would match, `externalId` wins (it is checked first).","example":"identity"},"personId":{"type":"string","format":"uuid","description":"ID of the existing person that conflicted. Use it with GET /api/v1/people/{id} or PATCH /api/v1/people/{id}."}},"required":["status","error","conflictType","personId"]}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/{id}/experiences":{"post":{"summary":"Add a work experience to a person","description":"Use this endpoint to add a single work-experience entry to an existing person.\n\nProvide the company name (and optionally its website domain and/or LinkedIn company id) — Atlas resolves or creates the matching Company and links it. A domain/LinkedIn match links to that Company even when the supplied name differs. Supplying `companyId` instead links the exact existing Company and skips matching entirely. The role, start date, and (optionally) end date and description complete the entry.\n\nUnlike the bulk experience data on `POST /api/v1/people` (which is created asynchronously), this endpoint creates the experience **synchronously** and returns it. Adding an experience recalculates the person's headline.","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Atlas person ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"companyName":{"type":"string","minLength":1,"maxLength":255,"description":"Company name","example":"Dunder Mifflin"},"companyId":{"type":["string","null"],"format":"uuid","description":"Atlas company ID to link this experience to an existing company. When provided, no new company is created and name/domain/LinkedIn matching is skipped. On update, an explicit `null` unlinks the company; when present (id or null) it takes precedence over `companyName` matching.","example":"550e8400-e29b-41d4-a716-446655440000"},"companyLinkedinId":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Company LinkedIn ID","example":"12345678"},"companyDomain":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Company website domain (bare domain; full URLs are accepted and normalized to their hostname)","example":"dundermifflin.com"},"role":{"type":"string","minLength":1,"maxLength":255,"description":"Job title/role","example":"Regional Manager"},"description":{"type":["string","null"],"minLength":1,"maxLength":16384,"description":"Role description","example":"Responsible for managing Scranton branch."},"startDate":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Start date (YYYY-MM-DD)","example":"2021-04-01"},"endDate":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"End date (YYYY-MM-DD, null = current)","example":null}},"required":["companyName","role","startDate"]}}}},"responses":{"201":{"description":"Experience created","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"companyName":{"type":"string","example":"Dunder Mifflin"},"companyId":{"type":["string","null"],"format":"uuid","example":"550e8400-e29b-41d4-a716-446655440000"},"companyLinkedinId":{"type":["string","null"],"example":"12345678"},"companyDomain":{"type":["string","null"],"example":"dundermifflin.com"},"role":{"type":"string","example":"Regional Manager"},"description":{"type":["string","null"]},"startDate":{"type":["string","null"],"example":"2021-04-01"},"endDate":{"type":["string","null"],"example":null}},"required":["id","companyName","companyId","companyLinkedinId","companyDomain","role","description","startDate","endDate"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Person not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/{id}/experiences/{experienceId}":{"patch":{"summary":"Update a person’s work experience","description":"Use this endpoint to update a single work-experience entry. Every field is optional — send only what you want to change; at least one field is required. Supplying `companyName` (with optional `companyDomain`/`companyLinkedinId`) re-resolves and re-links the Company — a domain/LinkedIn match wins even when the name differs; supplying `companyId` links the exact existing Company and skips matching. Updating an experience recalculates the person's headline.","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Atlas person ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Experience ID","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"},"required":true,"name":"experienceId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateExperiencePayload"}}}},"responses":{"200":{"description":"Experience updated","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"companyName":{"type":"string","example":"Dunder Mifflin"},"companyId":{"type":["string","null"],"format":"uuid","example":"550e8400-e29b-41d4-a716-446655440000"},"companyLinkedinId":{"type":["string","null"],"example":"12345678"},"companyDomain":{"type":["string","null"],"example":"dundermifflin.com"},"role":{"type":"string","example":"Regional Manager"},"description":{"type":["string","null"]},"startDate":{"type":["string","null"],"example":"2021-04-01"},"endDate":{"type":["string","null"],"example":null}},"required":["id","companyName","companyId","companyLinkedinId","companyDomain","role","description","startDate","endDate"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Person or experience not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or path parameters failed validation. This includes the date-order rule, which is checked against the stored record: sending only `startDate` (or only `endDate`) fails with an `endDate` field error when the resulting range would end before it starts.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"delete":{"summary":"Delete a person’s work experience","description":"Use this endpoint to remove a single work-experience entry from a person. Deleting an experience recalculates the person's headline.","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Atlas person ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Experience ID","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"},"required":true,"name":"experienceId","in":"path"}],"responses":{"200":{"description":"Experience deleted","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Person or experience not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/{personId}/identities":{"get":{"summary":"Get identities for a person","description":"Use this endpoint to retrieve all contact details (known as \"identities\") stored against a specific person in Atlas.\n\nIn Atlas, a person can have multiple ways of being contacted - for example, two email addresses, a mobile number, and a LinkedIn profile. Each of these is stored as a separate \"identity\". This endpoint returns all of them for a given person.\n\nTo use it, you need the person's Atlas ID (a unique identifier, returned by other endpoints like Search or Lookup). You can optionally filter the results to only return a specific type - for example, only phone numbers or only emails.\n\nEach identity returned includes its type (e.g. `email`, `phone`, `linkedin`), its value (e.g. `john@example.com`), and whether it is currently active.","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Person ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"personId","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Comma-separated identity types to filter by","example":"email,phone"},"required":false,"name":"type","in":"query"}],"responses":{"200":{"description":"Person identities","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"value":{"type":"string"},"active":{"type":"boolean"}},"required":["type","value","active"]}}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/notes":{"put":{"summary":"Create a note on a person","description":"Use this endpoint to add a written note to a person's record in Atlas.\n\nNotes are a way of recording important context about a candidate or contact - for example, a summary of a phone call, a reminder about their availability, or any other information you want to log against them.\n\n**Identifying the person:**\nProvide exactly one of:\n- `email` - the person's email address. Atlas finds the person by matching against their stored email identities.\n- `personId` - the Atlas person ID. Atlas looks the person up directly within your agency.\n- `linkedinUrl` - the person's LinkedIn profile URL. Atlas normalises the URL (stripping protocol, `www.`, trailing slash, case) before matching against the person's stored LinkedIn identity. Public profile (`linkedin.com/in/...`), Talent/Recruiter, and Sales Navigator URLs are all supported.\n- `phone` - the person's phone number. Atlas matches against stored phone identities across common formatting variations. The number must map to exactly one person; if it is ambiguous (matches more than one person) the request returns 404 and you should use `personId` instead.\n\nIf zero or more than one identifier is supplied, the request is rejected with a 422 validation error.\n\n**Identifying the note author:**\n- `ownerEmail` - the email address of the Atlas user the note should be attributed to.\n\n**What you also provide:**\n- The text content of the note (`note`)\n- `projectId` (optional) - an Atlas project ID to scope the note to that person's candidacy on the project, the same way a note added from a project's candidate drawer is scoped inside Atlas. When set, the note shows only on that project's candidate drawer; when omitted the note is person-level and shows everywhere. A `projectId` that does not exist in your agency is rejected with a 404.\n\nThe note will be created as type `manual` and will appear on that person's timeline inside Atlas, visible to your team.","tags":["People"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateNotePayload"}}}},"responses":{"201":{"description":"Note created","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"text":{"type":"string"},"type":{"type":"string","enum":["manual","phone_call"]},"personId":{"type":"string","format":"uuid"},"projectId":{"type":["string","null"],"format":"uuid","description":"Project the note is scoped to (candidacy note), or null for a person-level note. Echoes the projectId from the request."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","text","type","personId","projectId","createdAt","updatedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Person, user, or project not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"get":{"summary":"List notes across the agency","description":"Use this endpoint to keep an external system in step with the notes your recruiters record against people in Atlas - for example, to report on business-development activity such as a logged BD call.\n\nThis is the agency-wide counterpart to `POST /api/v1/people/notes/lookup`. The lookup endpoint requires you to name the people you are interested in (up to 100 per request), which means an account with tens of thousands of contacts would need hundreds of calls per polling cycle. This endpoint instead returns every note in your agency, newest change first, so you can sync on a timestamp cursor and never send a person ID at all.\n\n**Keeping in sync (recommended pattern):**\n1. **Backfill once.** Page through the endpoint using `createdAfter` / `createdBefore` to walk your history in windows (for example a month at a time). Both bounds are inclusive and accept either a full ISO 8601 datetime or a date-only `YYYY-MM-DD` value.\n2. **Record your cursor.** Keep the highest `updatedAt` you have *fully* processed. Seed it with the time your backfill *started*, not the time it finished, so a note edited while the backfill was running is picked up by your first poll instead of being skipped.\n3. **Poll a closed window.** On each subsequent poll (every few minutes is fine) send `updatedAfter` set to your stored cursor *minus a small overlap* - a minute or two, to absorb clock skew and rows committed mid-request - together with `updatedBefore` set to the current time. Pinning the upper bound matters: paging is offset-based over an `updatedAt` descending sort, so without it a note edited while you are partway through the pages jumps to the top of the result set and pushes a row you have not read yet off the end of the page you are about to request. The overlap means you will occasionally re-receive a note you already have, so **de-duplicate on `id`** and treat a repeat as an update rather than a new note.\n4. **Drain every page of that window.** `pageSize` caps at 100, so any interval with more changes than that spans several pages - keep requesting `page` 2, 3, ... until you have read `totalPages`. Because the sort is newest change first, the later pages hold the *older* changes in the window: stopping after page 1 and advancing the cursor puts them permanently behind it.\n5. **Advance the cursor** to the `updatedBefore` you pinned in step 3, and only once every page of the window has been processed. If a poll fails part-way, leave the cursor untouched and re-run the same window - de-duplicating on `id` makes the replay harmless.\n\nA note that is edited gets a fresh `updatedAt`, so edits arrive through the same cursor as new notes. Notes that have not changed are not returned.\n\n**Picking up deletions:**\nDeleted notes are retained as tombstones. Pass `includeDeleted=true` and a soft-deleted note comes back with its `deletedAt` populated (it is `null` for live notes); deleting a note also bumps its `updatedAt`, so deletions flow through your `updatedAfter` cursor alongside creations and edits. Without the flag, only live notes are returned.\n\n**Optional narrowing:**\n- `type` - comma-separated note types. `manual` is a note typed by a user, `phone_call` is a note logged against a call (these also carry a `phoneCallOutcome`), and `integration` is a note written by an inbound integration rather than a person.\n- `personId` - restrict to a single person, for drill-down. Leave it off for the sync path.\n\n**What you get back:**\nA paginated list of notes sorted by `updatedAt` descending, then by `id`. Each note includes its text, type, the person it belongs to, the project it is scoped to (`projectId` - set when the note was added against the person's candidacy on a specific project, null for a person-level note), the author (id, name, email), `phoneCallOutcome` where the note came from a logged call, and its timestamps.\n\nResults are always scoped to your agency, and an empty result set is a successful response with an empty `data` array, not an error.","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Drill down to the notes of a single person. Omit to list notes across the whole agency (the incremental-sync path).","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"personId","in":"query"},{"schema":{"type":"string","description":"Comma-separated note types to include. One or more of: phone_call, manual, integration. Defaults to all types.","example":"phone_call"},"required":false,"name":"type","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only notes created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only notes created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only notes updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only notes updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted notes as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated agency-wide notes, sorted by updatedAt descending","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"personId":{"type":"string","format":"uuid"},"projectId":{"type":["string","null"],"format":"uuid","description":"Project the note is scoped to, when it was added against the person’s candidacy on a specific project. Null for a person-level note."},"text":{"type":"string","example":"BD call - happy for us to send two profiles this week."},"type":{"type":"string","enum":["manual","phone_call","integration"],"example":"phone_call"},"phoneCallOutcome":{"type":["string","null"],"enum":["no_answer","interested","not_interested","replied"],"description":"Outcome when the note came from a logged phone call, otherwise null"},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Sarah Johnson"},"email":{"type":["string","null"],"example":"sarah@agency.com"}},"required":["id","name","email"],"description":"Note author, or null if the author can no longer be resolved"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time","description":"When the note was deleted, or null if it is live. Only ever non-null when the request passed includeDeleted=true."}},"required":["id","personId","projectId","text","type","phoneCallOutcome","createdBy","createdAt","updatedAt","deletedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":100},"total":{"type":"integer","description":"Total matching notes","example":1},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]},"example":{"status":"ok","data":[{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","personId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","projectId":null,"text":"BD call - happy for us to send two profiles this week.","type":"phone_call","phoneCallOutcome":"interested","createdBy":{"id":"c3d4e5f6-a7b8-9012-cdef-123456789012","name":"Sarah Johnson","email":"sarah@agency.com"},"createdAt":"2026-06-15T14:30:00.000Z","updatedAt":"2026-06-15T14:30:00.000Z","deletedAt":null}],"pagination":{"page":1,"pageSize":100,"total":1,"totalPages":1}}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/notes/lookup":{"post":{"summary":"Look up notes for people","description":"Use this endpoint to retrieve the notes recorded against one or more people in your Atlas account - for example, to build an activity report, populate a dashboard, or sync recruiter activity into another system.\n\nUnlike the create-note endpoint (`PUT /api/v1/people/notes`), this is a read-only endpoint. You send a list of person IDs (up to 100 at a time) and, optionally, a date range, and Atlas returns every matching note - including who wrote it and when.\n\n**What you provide:**\n- `personIds` - the Atlas person IDs whose notes you want (1-100).\n- `createdAfter` / `createdBefore` - optional ISO 8601 timestamps to restrict the notes to a date range. Both bounds are **inclusive**.\n- `page` / `pageSize` - optional pagination controls (`pageSize` defaults to 100, maximum 100).\n\n**What you get back:**\nA paginated list of notes sorted by creation date, newest first. Each note includes its text, type, the person it belongs to, the project it is scoped to (`projectId` - set when the note was added against the person's candidacy on a specific project, null for a person-level note), the author (id, name, email), and timestamps. A note created from a logged phone call also includes the `phoneCallOutcome`; for manually-written notes this is `null`.\n\nResults are always scoped to your agency - person IDs that belong to another agency simply return no notes. An empty result set is a successful response with an empty `data` array, not an error.","tags":["People"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"personIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":100,"description":"Atlas person IDs to fetch notes for (minimum 1, maximum 100).","example":["a1b2c3d4-e5f6-7890-abcd-ef1234567890","b2c3d4e5-f6a7-8901-bcde-f12345678901"]},"createdAfter":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only notes created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"createdBefore":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only notes created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"page":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"pageSize":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"perPage":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true}},"required":["personIds"]}}}},"responses":{"200":{"description":"Paginated notes for the requested people, sorted by createdAt descending","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"personId":{"type":"string","format":"uuid"},"projectId":{"type":["string","null"],"format":"uuid","description":"Project the note is scoped to, when it was added against the person’s candidacy on a specific project. Null for a person-level note."},"text":{"type":"string","example":"Spoke with candidate. Very interested in the senior role."},"type":{"type":"string","enum":["manual","phone_call"],"example":"manual"},"phoneCallOutcome":{"type":["string","null"],"enum":["no_answer","interested","not_interested","replied"],"description":"Outcome when the note came from a logged phone call, otherwise null"},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Sarah Johnson"},"email":{"type":["string","null"],"example":"sarah@agency.com"}},"required":["id","name","email"],"description":"Note author, or null if the author can no longer be resolved"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","personId","projectId","text","type","phoneCallOutcome","createdBy","createdAt","updatedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":50},"total":{"type":"integer","description":"Total matching notes","example":1},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]},"example":{"status":"ok","data":[{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","personId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","projectId":"9f8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d","text":"Spoke with candidate. Very interested in the senior role.","type":"manual","phoneCallOutcome":null,"createdBy":{"id":"c3d4e5f6-a7b8-9012-cdef-123456789012","name":"Sarah Johnson","email":"sarah@agency.com"},"createdAt":"2025-06-15T14:30:00.000Z","updatedAt":"2025-06-15T14:30:00.000Z"}],"pagination":{"page":1,"pageSize":50,"total":1,"totalPages":1}}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/identities":{"get":{"summary":"Bulk identity feed for contact sync","description":"Use this endpoint to download a full list of contact details (phone numbers, email addresses, LinkedIn URLs, etc.) for everyone in your Atlas account - intended for syncing contacts into an external system such as a VOIP phone system or address book.\n\nFor example, a phone system integration could call this endpoint periodically to keep its contact list up to date, so that when a candidate calls in, their name automatically appears on screen.\n\n**How it works:**\n- Results are returned in the order they were added to Atlas (oldest first), making it easy to pick up only what's new since your last sync\n- You can filter by a `since` date to only fetch identities added or updated after a certain point\n- Results are paginated - if there are more results than fit in one response, `hasMore` will be `true` and you can fetch the next page\n\n**Important:** This feed is designed for background syncing and caching, not for real-time lookups. You may occasionally receive duplicate entries - your system should handle this by updating existing records rather than creating duplicates (known as an \"upsert\"). Use the `identityId` field as the unique key when doing so.","tags":["People"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only identities created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only identities created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only identities updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only identities updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"}],"responses":{"200":{"description":"Paginated list of contact identities","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"identityId":{"type":"string","format":"uuid","description":"Identity ID"},"type":{"type":"string","description":"Identity type (email, phone, linkedin, etc.)","example":"phone"},"value":{"type":"string","description":"Identity value","example":"+447786267677"},"createdAt":{"type":"string","format":"date-time","description":"When the identity was created"},"updatedAt":{"type":"string","format":"date-time","description":"When the identity was last updated"},"person":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"],"example":"Jordan"},"lastName":{"type":["string","null"],"example":"Shlosberg"}},"required":["id","firstName","lastName"]}},"required":["identityId","type","value","createdAt","updatedAt","person"]}},"page":{"type":"integer","description":"Use pagination.page instead","deprecated":true},"pageSize":{"type":"integer","description":"Use pagination.pageSize instead","deprecated":true},"hasMore":{"type":"boolean","description":"Use pagination instead","deprecated":true},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":100},"total":{"type":"integer","description":"Total matching items","example":500},"totalPages":{"type":"integer","description":"Total number of pages","example":5}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","page","pageSize","hasMore","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects":{"get":{"summary":"List projects","description":"Use this endpoint to retrieve a list of projects (also called jobs or roles) from your Atlas account.\n\nIn Atlas, a **project** represents a recruitment assignment - for example, \"Head of Engineering at Acme Corp\" or \"Sales Manager at Globex\". This endpoint returns a summary list of all your projects, which you can filter and page through.\n\n**What you can filter by:**\n- `state` - the current status of the project. Options are: `active` (being worked on), `closed` (finished), `on_hold` (paused), `lead` (a potential opportunity not yet confirmed), `pitch` (being pitched to a client), `opportunity` (a speculative role), or `talent_pool` (a pipeline of candidates not tied to a specific role). You can pass multiple states at once.\n- `createdAfter` / `createdBefore` - only return projects created within a specific date range\n- `updatedAfter` / `updatedBefore` - only return projects last modified within a specific date range (ISO 8601). Filters on the `updatedAt` field. To keep an external copy in sync, use `updatedAfter` as a cursor: persist the maximum `updatedAt` you receive and pass it back as `updatedAfter` on the next run to fetch only what changed since (the bound is inclusive, so upsert by `id` to stay idempotent)\n- `ownerEmail` - only return projects owned by a specific consultant (identified by their Atlas email address)\n- `memberEmail` - only return projects where a specific consultant is a team member\n- `companyId` - only return projects belonging to a specific company (multiple comma-separated company IDs return the union)\n- `role` - filter by job title (partial matches are supported)\n- `public` - filter by public (job-board) visibility. `public=true` returns only the projects advertised on your public job board, `public=false` only the ones that are not, and omitting it returns both. Combine with `state=active` to get exactly the set the public jobs portal advertises\n- `includeDeleted` - include soft-deleted projects as tombstones (with a populated `deletedAt`)\n\n**Unknown query parameters are rejected:**\nAny parameter that is not documented here is rejected with a `422` — the endpoint never silently ignores a filter it does not understand, so a typo (or a filter that does not exist) can never come back as a `200` with the full unfiltered list.\n\n**Pagination:**\nResults are returned in pages. Use the `page` and `pageSize` parameters to move through large result sets. The response includes a `total` count so you know how many results exist in total.\n\n**Requesting extra job metadata (`expand`):**\nBy default the list returns the summary fields described below. Pass a comma-separated `expand` list to have selected job metadata returned on every row, so an incremental sync does not need a follow-up detail call per project:\n\n`GET /api/v1/projects?updatedAfter=2026-06-01T00:00:00Z&expand=contractType,func,salary,seniority,workMode`\n\n- Allowed values: `contractType`, `func`, `salary`, `seniority`, `workMode`. Any other value is rejected with `422`.\n- `expand=salary` returns both `salary` and `salaryCurrency`.\n- Each field holds the same value and enum shape as the corresponding field on **Get project details**, and is `null` when the project has no value set.\n- `expand` combines freely with every filter and with pagination — expanded fields appear on all pages.\n- **Backward compatible:** omit `expand` (or send it empty) and the response is exactly as before — a field you did not request is absent from the row, not returned as `null`.\n\n**What you get back:**\nEach project in the list includes its ID, job role title, current state, the associated company, and the `owner` (the consultant the project is attributed to, as `{ userId, email, name }`). The `owner` is the primary recruiter attribution field for rolling projects up per recruiter; it is `null` when the project has no owner (projects have no creator fallback). To get the full details of a specific project (including pipeline stages, salary, members, etc.), use the **Get project details** endpoint with the project's ID.","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Comma-separated project states to filter by","example":"active,lead"},"required":false,"name":"state","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only projects created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only projects created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only projects updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only projects updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted projects as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"string","description":"Comma-separated project owner emails","example":"owner1@agency.com,owner2@agency.com"},"required":false,"name":"ownerEmail","in":"query"},{"schema":{"type":"string","description":"Comma-separated project member emails","example":"member1@agency.com,member2@agency.com"},"required":false,"name":"memberEmail","in":"query"},{"schema":{"type":"string","description":"Comma-separated company UUIDs — only projects belonging to these companies are returned","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"companyId","in":"query"},{"schema":{"type":"string","description":"Filter by job role (case-insensitive partial match)","example":"Software Engineer"},"required":false,"name":"role","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by public (job-board) visibility. `true` returns only projects advertised on the public job board, `false` only the ones that are not. Omit to return both.","example":"true"},"required":false,"name":"public","in":"query"},{"schema":{"type":"string","description":"Comma-separated job metadata fields to add to every list row. Omit it (or send an empty value) and the response shape is unchanged. Unknown values are rejected with 422. Allowed values: contractType, func, salary, seniority, workMode. Requesting `salary` also returns `salaryCurrency`.","example":"contractType,func,salary,seniority,workMode"},"required":false,"name":"expand","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of projects","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"jobRole":{"type":"string","description":"Job role/title","example":"Software Engineer"},"state":{"type":"string","description":"Project state","example":"active"},"jobDescriptionFormatted":{"type":["string","null"],"description":"Job description in formatted HTML"},"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"],"description":"Associated company, null for talent pool projects"},"owner":{"type":["object","null"],"properties":{"userId":{"type":"string","format":"uuid","description":"Owner user ID"},"email":{"type":"string","description":"Owner email","example":"owner@agency.com"},"name":{"type":"string","description":"Owner name","example":"Jane Smith"}},"required":["userId","email","name"],"description":"Project owner"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Custom attribute values for this project"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 — when the project was last modified. Use as the incremental-sync cursor: persist the maximum value across a page and pass it back as `updatedAfter`","example":"2026-06-01T12:00:00.000Z"},"createdAt":{"type":["string","null"],"description":"ISO 8601 — when the project was created","example":"2026-06-01T12:00:00.000Z"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live projects; populated for tombstones (only returned when `includeDeleted=true`)"},"contractType":{"type":["string","null"],"enum":["full_time","part_time","contract","non_exec"],"description":"Contract type. Returned only when `expand` includes `contractType`"},"func":{"type":["string","null"],"description":"Job function. Returned only when `expand` includes `func`"},"salary":{"type":["string","null"],"description":"Salary or salary range. Returned only when `expand` includes `salary`"},"salaryCurrency":{"type":["string","null"],"description":"Salary currency code. Returned only when `expand` includes `salary`","example":"USD"},"seniority":{"type":["string","null"],"enum":["partner","board","founder","cxo","vp","director","manager","senior","middle","junior"],"description":"Seniority level. Returned only when `expand` includes `seniority`"},"workMode":{"type":["string","null"],"enum":["office","hybrid","remote"],"description":"Work mode. Returned only when `expand` includes `workMode`"}},"required":["id","jobRole","state","jobDescriptionFormatted","company","owner","customAttributes","updatedAt","createdAt","deletedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":42},"totalPages":{"type":"integer","description":"Total number of pages","example":2}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Create a project","description":"Use this endpoint to create a new project (job, job lead, or talent pool) in your Atlas account.\n\n**Project types:**\n- `job` — an active recruitment assignment for a confirmed role at a client company. Maps to project state `active`.\n- `job_lead` — a potential opportunity that is not yet confirmed. Maps to project state `lead`. Optionally link to an existing opportunity via `opportunityId`.\n- `talent_pool` — a pipeline of candidates not tied to a specific role. Maps to project state `talent_pool`. Does not require a `companyId`.\n\n**Required fields:**\n- `projectType`, `jobRole`, `ownerEmail` (and `companyId` for job and job_lead).\n\n**Validation rules:**\n- `ownerEmail` must resolve to an existing user within the agency.\n- `companyId`, when provided, must belong to the same agency.\n- `opportunityId`, when provided, must belong to the same agency.\n- `memberEmails` entries that don't match a user in the agency are silently skipped — only matching emails are added as members.\n- `companyContactIds` must be CompanyContact junction IDs (the person↔company link, retrievable from `companyContact.id` on GET /api/v1/people/{id}) belonging to the project's `companyId` — not person IDs.\n- `customAttributes[].customAttributeId` must reference a project-scoped custom attribute in the agency.\n\nThe owner is automatically added as a project member with the lead role; additional `memberEmails` are added as regular members.","tags":["Projects"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"projectType":{"type":"string","enum":["job","job_lead","talent_pool"],"description":"Type of project: job, job_lead, talent_pool","example":"job"},"jobRole":{"type":"string","minLength":1,"maxLength":255,"description":"Job title or role name","example":"Senior Software Engineer"},"companyId":{"type":["string","null"],"format":"uuid","description":"Company this project belongs to. Required for job and job_lead"},"ownerEmail":{"type":"string","maxLength":255,"format":"email","description":"Email of the project owner. Must be an existing, active user in the agency","example":"owner@agency.com"},"jobDescription":{"type":["string","null"],"maxLength":16384,"description":"Plain text job description"},"jobNumber":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Job reference number. Must be non-empty when supplied; omit to auto-generate.","example":"ENG-2026-042"},"contractType":{"type":["string","null"],"enum":["full_time","part_time","contract","non_exec"],"description":"Contract type"},"workMode":{"type":["string","null"],"enum":["office","hybrid","remote"],"description":"Work mode"},"seniority":{"type":["string","null"],"enum":["partner","board","founder","cxo","vp","director","manager","senior","middle","junior","training","unpaid"],"description":"Seniority level","example":"senior"},"func":{"type":["string","null"],"minLength":1,"maxLength":80,"description":"Job function (e.g. Engineering, Sales). Empty strings are rejected — omit or send null to clear.","example":"Engineering"},"sourceUrl":{"type":["string","null"],"minLength":1,"maxLength":2048,"description":"URL where this project was sourced from","example":"https://example.com/jobs/senior-engineer"},"skills":{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":80},"maxItems":100,"description":"List of required skills. Duplicate entries are silently dropped.","example":["TypeScript","React"]},"hireTarget":{"type":"integer","minimum":1,"maximum":10000,"default":1,"description":"Number of positions to fill","example":1},"visaSupport":{"type":["boolean","null"],"description":"Whether visa sponsorship is offered"},"public":{"type":"boolean","default":false,"description":"Whether the project is publicly visible"},"salary":{"type":["object","null"],"properties":{"value":{"type":"string","minLength":1,"maxLength":80,"description":"Salary or salary range (free text)","example":"120000-150000"},"currency":{"type":"string","enum":["USD","EUR","JPY","GBP","AUD","CAD","CHF","CNY","HKD","NZD","SEK","NOK","MXN","SGD","RUB","ZAR","TRY","BRL","INR","KRW","DKK","PLN","ILS","HUF","CZK","RON","THB","MYR","IDR","VND","PHP","SAR","AED","QAR","KWD","JOD","CLP","COP","PEN","ARS","UYU","CRC","PKR","BDT","LKR","EGP","NGN","TWD","KES","GHS","UGX","TZS","MAD","BWP","BGN","UAH","KZT","GEL","ISK","BHD","OMR"],"description":"Currency code","example":"USD"}},"required":["value","currency"],"description":"Salary"},"expectedFee":{"type":["object","null"],"properties":{"value":{"type":"string","pattern":"^(0|[1-9]\\d{0,17})(\\.\\d{1,2})?$","description":"Expected placement fee amount (decimal string, numeric(20,2))","example":"25000.00"},"currency":{"type":"string","enum":["USD","EUR","JPY","GBP","AUD","CAD","CHF","CNY","HKD","NZD","SEK","NOK","MXN","SGD","RUB","ZAR","TRY","BRL","INR","KRW","DKK","PLN","ILS","HUF","CZK","RON","THB","MYR","IDR","VND","PHP","SAR","AED","QAR","KWD","JOD","CLP","COP","PEN","ARS","UYU","CRC","PKR","BDT","LKR","EGP","NGN","TWD","KES","GHS","UGX","TZS","MAD","BWP","BGN","UAH","KZT","GEL","ISK","BHD","OMR"],"description":"Currency code","example":"USD"}},"required":["value","currency"],"description":"Expected placement fee"},"feeTerms":{"type":["string","null"],"minLength":1,"maxLength":80,"description":"Fee terms description","example":"20% of annual salary"},"notes":{"type":["string","null"],"maxLength":16384,"description":"Internal notes"},"startedAt":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Planned or actual start date (YYYY-MM-DD). Must be no earlier than one calendar month ago. Stored as UTC midnight of that calendar day.","example":"2026-04-01"},"location":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Display name / formatted address (e.g. \"London, UK\")","example":"London, UK"},"locality":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"City name","example":"London"},"region":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"State or region","example":"England"},"metro":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Metro area (People Data Labs convention, e.g. \"new york, new york\")","example":"new york, new york"},"country":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Country name","example":"United Kingdom"},"streetAddress":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 1","example":"123 Baker Street"},"addressLine2":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 2 (apartment, suite, unit, etc.)","example":"Suite 200"},"postalCode":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Postal / ZIP code","example":"NW1 6XE"},"raw":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Raw unstructured address text (used for geocoding)","example":"123 Baker Street, London NW1 6XE, United Kingdom"},"latitude":{"type":["number","null"],"minimum":-90,"maximum":90,"description":"Latitude coordinate (-90..90)","example":51.523767},"longitude":{"type":["number","null"],"minimum":-180,"maximum":180,"description":"Longitude coordinate (-180..180)","example":-0.158519}},"description":"Location"},"memberEmails":{"type":"array","items":{"type":"string","maxLength":255,"format":"email"},"maxItems":100,"default":[],"description":"Emails of team members to add. Must be existing users in the agency. Duplicate entries are silently dropped."},"companyContactIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"default":[],"description":"CompanyContact IDs to associate with the project — these are the person↔company link IDs, NOT person IDs. Retrieve a value from the `companyContact.id` field on GET /api/v1/people/{id}. Each must belong to the project's `companyId`; passing a person ID (or an ID from another company) returns 404. Duplicate entries are silently dropped."},"opportunityId":{"type":["string","null"],"format":"uuid","description":"Opportunity to link this project to. Only allowed for job_lead"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"customAttributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID — must belong to the agency at the correct scope."},"optionId":{"type":["string","null"],"format":"uuid","description":"Required for `options`-type attributes — set to the chosen option's UUID. Mutually exclusive with `value`. For multi-select (`multipleValues: true`), repeat the entry once per chosen optionId."},"value":{"anyOf":[{"type":"string","minLength":1,"maxLength":16384},{"type":"number"},{"type":"null"}],"description":"Attribute value. Shape depends on the attribute `type`:\n- `text_line` / `text_block`: non-empty string (trimmed, max 16384 chars).\n- `integer` / `number_input`: JSON number, integer only, must fit Postgres int32 (-2147483648..2147483647).\n- `date`: string `YYYY-MM-DD`, calendar-validated.\n- `options`: do not send `value` — use `optionId` instead.\nMutually exclusive with `optionId`. Sending neither is rejected (422).","example":"Some text value"}},"required":["customAttributeId"],"description":"Custom attribute value"},"maxItems":100,"default":[],"description":"Custom attribute values to set on the project. Each entry references a custom attribute definition by id (must be `of: \"project\"` and belong to your agency).\n\nHow to send a value depends on the attribute `type`:\n\n- `text_line` — short single-line text. Send `value` as a non-empty string (trimmed, max 16384 chars).\n- `text_block` — rich/multi-line text. Send `value` as a non-empty string (trimmed, max 16384 chars).\n- `integer` — whole number. Send `value` as a JSON number (no quotes). Must fit Postgres int32 (-2147483648..2147483647); decimals are rejected.\n- `number_input` — same shape as `integer` (legacy alias).\n- `date` — calendar date with no timezone. Send `value` as `YYYY-MM-DD`; the calendar is validated, so `2026-13-32` is rejected.\n- `options` — pick from a fixed list. Send `optionId` (a UUID of one of the attribute's options). Do **not** send `value`. For attributes with `multipleValues: true`, repeat the same `customAttributeId` with each distinct `optionId` you want to set.\n\nValidation rules enforced by the API:\n- Either `value` or `optionId` must be present — sending only `customAttributeId` is rejected (422).\n- `optionId` and `value` are mutually exclusive — sending both is rejected (422).\n- For single-value attributes (`multipleValues: false` or non-`options` types) each `customAttributeId` may appear only once. Duplicates are rejected (422).\n- For `options` multi-select, repeating the same (`customAttributeId`, `optionId`) pair is rejected (422).\n- Wrong-type values are rejected (422): e.g. sending a string for `integer`, a number for `date`.\n- The referenced `customAttributeId` and `optionId` must belong to your agency and the option must belong to the specified attribute, otherwise 404 / 422.\n\nExample — one of each non-trivial type:\n```json\n\"customAttributes\": [\n  { \"customAttributeId\": \"…uuid…\", \"value\": \"EU\" },           // text_line\n  { \"customAttributeId\": \"…uuid…\", \"value\": 3 },              // integer\n  { \"customAttributeId\": \"…uuid…\", \"value\": \"2026-06-01\" },   // date\n  { \"customAttributeId\": \"…uuid…\", \"optionId\": \"…uuid…\" },    // options (single-select)\n  { \"customAttributeId\": \"…uuid…\", \"optionId\": \"…uuid-a…\" }, // options multi-select…\n  { \"customAttributeId\": \"…uuid…\", \"optionId\": \"…uuid-b…\" }  // …same attribute, two picks\n]\n```"}},"required":["projectType","jobRole","ownerEmail"]}}}},"responses":{"201":{"description":"Project created","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"jobRole":{"type":"string"},"jobNumber":{"type":["string","null"]},"state":{"type":"string"}},"required":["id","jobRole","jobNumber","state"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Owner, company, contact, opportunity, or custom attribute not found / not in scope","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"A project with the provided jobNumber already exists in this agency.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Project with jobNumber \"JOB-123\" already exists"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/{id}":{"get":{"summary":"Get project details","description":"Use this endpoint to retrieve the full details of a single project by its ID.\n\nThis is the endpoint to use when you already know which project you want - for example, after finding it via the List projects endpoint. It returns everything Atlas knows about the project in one response.\n\n**What you need to provide:**\nJust the project's ID in the URL path (e.g. `/api/v1/projects/abc-123`). Project IDs are UUIDs - long strings of letters and numbers - returned by the List projects endpoint.\n\n**What you get back:**\n\n- **Basic info** - job role title, job reference number, current state (active, closed, etc.), and close reason if applicable\n- **Job details** - contract type (full-time, part-time, contract, non-exec), work mode (office, hybrid, remote), seniority level, job function, required skills, target number of hires, and whether visa support is offered\n- **Compensation** - salary, salary currency, expected fee, fee currency, and fee terms\n- **Notes** - internal notes about the project\n- **Location** - city, region, country, and formatted address\n- **Company** - the client company the role is for, including their name, industry, size, and logo\n- **Owner** - the consultant responsible for the project\n- **Members** - all consultants working on the project and their role (member or lead)\n- **Pipeline stages and statuses** - the full recruitment pipeline configured for this project (e.g. Sourcing → Longlist → Shortlist → Offer). Each stage contains its statuses (e.g. \"Added\", \"Reviewed\", \"Approved\"). These IDs are needed when adding a candidate to the project.\n- **Custom attributes** - any agency-specific fields configured in your Atlas account\n- **Dates** - when the project was created, started, and closed","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Extended project details","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"jobRole":{"type":"string","description":"Job role/title","example":"Software Engineer"},"jobNumber":{"type":["string","null"],"description":"Job reference number"},"state":{"type":"string","enum":["active","closed","on_hold","talent_pool","lead","pitch","opportunity"],"description":"Project state","example":"active"},"closeReason":{"type":["string","null"],"enum":["filled","worked_lost","cancelled","lead_lost","opportunity_lost","pitch_lost"],"description":"Reason the project was closed."},"jobDescription":{"type":["string","null"],"description":"Plain text job description"},"jobDescriptionFormatted":{"type":["string","null"],"description":"Job description in formatted HTML"},"public":{"type":"boolean","description":"Whether the project is public"},"contractType":{"type":["string","null"],"enum":["full_time","part_time","contract","non_exec"],"description":"Contract type"},"workMode":{"type":["string","null"],"enum":["office","hybrid","remote"],"description":"Work mode"},"seniority":{"type":["string","null"],"enum":["partner","board","founder","cxo","vp","director","manager","senior","middle","junior"],"description":"Seniority level"},"func":{"type":["string","null"],"description":"Job function"},"skills":{"type":["array","null"],"items":{"type":"string"},"description":"Required skills","example":["TypeScript","React"]},"hireTarget":{"type":["integer","null"],"description":"Target number of hires","example":1},"visaSupport":{"type":["boolean","null"],"description":"Whether visa support is offered"},"salary":{"type":["string","null"],"description":"Salary or salary range"},"salaryCurrency":{"type":["string","null"],"description":"Salary currency code","example":"USD"},"expectedFee":{"type":["string","null"],"description":"Expected fee amount"},"expectedFeeCurrency":{"type":["string","null"],"description":"Expected fee currency code"},"feeTerms":{"type":["string","null"],"description":"Fee terms"},"notes":{"type":["string","null"],"description":"Internal notes about the project"},"location":{"type":["object","null"],"properties":{"city":{"type":["string","null"],"description":"City/locality","example":"London"},"region":{"type":["string","null"],"description":"Region/state","example":"England"},"country":{"type":["string","null"],"description":"Country","example":"United Kingdom"},"formattedAddress":{"type":["string","null"],"description":"Full formatted address","example":"London, England, United Kingdom"}},"required":["city","region","country","formattedAddress"],"description":"Project location"},"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Acme Corp"},"industry":{"type":["string","null"],"description":"Primary industry","example":"Technology"},"size":{"type":["string","null"],"description":"Company size","example":"51-200"},"logoUrl":{"type":["string","null"],"description":"Company logo URL"}},"required":["id","name","industry","size","logoUrl"],"description":"Associated company"},"owner":{"type":["object","null"],"properties":{"userId":{"type":"string","format":"uuid","description":"Owner user ID"},"email":{"type":"string","description":"Owner email","example":"owner@agency.com"},"name":{"type":"string","description":"Owner name","example":"Jane Smith"}},"required":["userId","email","name"],"description":"Project owner"},"members":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string","format":"uuid","description":"Member user ID"},"email":{"type":"string","description":"Member email","example":"member@agency.com"},"name":{"type":"string","description":"Member name","example":"John Doe"},"memberType":{"type":"string","enum":["member","lead"],"description":"Member role - lead is the project owner","example":"member"}},"required":["userId","email","name","memberType"]},"description":"Project members"},"stages":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Stage name","example":"Sourcing"},"type":{"type":"string","description":"Stage type","example":"sourcing"},"phase":{"type":"string","description":"Stage phase","example":"internal_selection"},"position":{"type":"integer","description":"Position in pipeline","example":0},"statuses":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Status name","example":"Added"},"type":{"type":"string","description":"Status type","example":"sourcing_added"},"position":{"type":"integer","description":"Position within stage","example":0}},"required":["id","name","type","position"]},"description":"Statuses within this stage"}},"required":["id","name","type","phase","position","statuses"]},"description":"Project stages with statuses"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Custom attribute values for this project"},"createdAt":{"type":["string","null"],"description":"ISO 8601 creation date"},"startedAt":{"type":["string","null"],"description":"Start date (YYYY-MM-DD)","example":"2026-03-23"},"closedAt":{"type":["string","null"],"description":"Close date (YYYY-MM-DD)","example":"2026-04-15"}},"required":["id","jobRole","jobNumber","state","closeReason","jobDescription","jobDescriptionFormatted","public","contractType","workMode","seniority","func","skills","hireTarget","visaSupport","salary","salaryCurrency","expectedFee","expectedFeeCurrency","feeTerms","notes","location","company","owner","members","stages","customAttributes","createdAt","startedAt","closedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"patch":{"summary":"Update a project","description":"Use this endpoint to update a project's fields, link it to an opportunity, and/or add hiring contacts to it.\n\nAll fields are optional, but at least one must be provided.\n\n**Partial-update semantics for scalar fields** (`jobRole`, `ownerEmail`, `jobDescription`, `notes`, `func`, `seniority`, `contractType`, `workMode`, `salary`, `expectedFee`, `hireTarget`, `visaSupport`, `public`, `location`, `startedAt`): omitted fields are left untouched, `null` clears a nullable field, and a value replaces the stored one. `jobRole`, `ownerEmail`, and `public` cannot be cleared. `ownerEmail` transfers ownership and must resolve to an existing, active user in the agency (404 otherwise).\n\n**Link fields:**\n- `opportunityId` — links the project to an existing opportunity. Only allowed for **job_lead** projects (state `lead`); sending it for any other project type returns 422. Linking an opportunity that is already linked to the project is a no-op.\n- `companyContactIds` — adds hiring contacts to the project. These are **CompanyContact junction IDs** (the person↔company link, retrievable from `companyContact.id` on GET /api/v1/people/{id}), not person IDs. Each must belong to the project's `companyId` — passing a person ID, an ID from another company, or an ID from another agency returns 404. This field is **additive only**: contacts already linked to the project are skipped (no-op) and existing links are never removed.\n\n**Not updatable here:** `companyId`, `state`, and `closedAt` — changing the company or closing a project have side effects on linked contacts, opportunities, and candidates and are deliberately excluded from this endpoint.\n\n**What you get back:**\nA core snapshot of the updated project (`id`, `jobRole`, `jobNumber`, `state`), the opportunity it is linked to (`null` when it has none), and the full list of hiring contact IDs linked to the project after the update — pre-existing links included. Use **Get project details** to read back all other fields.","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"opportunityId":{"type":"string","format":"uuid","description":"Opportunity to link this project to. Only allowed for job_lead projects (state `lead`) — sending it for any other project type returns 422. Linking the same opportunity again is a no-op.","example":"550e8400-e29b-41d4-a716-446655440005"},"companyContactIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"CompanyContact IDs to add as hiring contacts — these are the person↔company link IDs, NOT person IDs. Retrieve a value from the `companyContact.id` field on GET /api/v1/people/{id}. Each must belong to the project's `companyId`; passing a person ID (or an ID from another company) returns 404. Additive only: already-linked contacts are skipped (no-op), existing links are never removed. Duplicate entries are silently dropped.","example":["550e8400-e29b-41d4-a716-446655440006"]},"jobRole":{"type":"string","minLength":1,"maxLength":255,"description":"Job title or role name. Cannot be cleared — omit to leave untouched","example":"Senior Software Engineer"},"ownerEmail":{"type":"string","maxLength":255,"format":"email","description":"Transfer project ownership. Must be an existing, active user in the agency","example":"owner@agency.com"},"jobDescription":{"type":["string","null"],"maxLength":16384,"description":"Plain text job description. Send null to clear"},"notes":{"type":["string","null"],"maxLength":16384,"description":"Internal notes. Send null to clear"},"func":{"type":["string","null"],"minLength":1,"maxLength":80,"description":"Job function (e.g. Engineering, Sales). Empty strings are rejected — send null to clear.","example":"Engineering"},"seniority":{"type":["string","null"],"enum":["partner","board","founder","cxo","vp","director","manager","senior","middle","junior","training","unpaid"],"description":"Seniority level","example":"senior"},"contractType":{"type":["string","null"],"enum":["full_time","part_time","contract","non_exec"],"description":"Contract type. Send null to clear"},"workMode":{"type":["string","null"],"enum":["office","hybrid","remote"],"description":"Work mode. Send null to clear"},"salary":{"type":["object","null"],"properties":{"value":{"type":"string","minLength":1,"maxLength":80,"description":"Salary or salary range (free text)","example":"120000-150000"},"currency":{"type":"string","enum":["USD","EUR","JPY","GBP","AUD","CAD","CHF","CNY","HKD","NZD","SEK","NOK","MXN","SGD","RUB","ZAR","TRY","BRL","INR","KRW","DKK","PLN","ILS","HUF","CZK","RON","THB","MYR","IDR","VND","PHP","SAR","AED","QAR","KWD","JOD","CLP","COP","PEN","ARS","UYU","CRC","PKR","BDT","LKR","EGP","NGN","TWD","KES","GHS","UGX","TZS","MAD","BWP","BGN","UAH","KZT","GEL","ISK","BHD","OMR"],"description":"Currency code","example":"USD"}},"required":["value","currency"],"description":"Salary"},"expectedFee":{"type":["object","null"],"properties":{"value":{"type":"string","pattern":"^(0|[1-9]\\d{0,17})(\\.\\d{1,2})?$","description":"Expected placement fee amount (decimal string, numeric(20,2))","example":"25000.00"},"currency":{"type":"string","enum":["USD","EUR","JPY","GBP","AUD","CAD","CHF","CNY","HKD","NZD","SEK","NOK","MXN","SGD","RUB","ZAR","TRY","BRL","INR","KRW","DKK","PLN","ILS","HUF","CZK","RON","THB","MYR","IDR","VND","PHP","SAR","AED","QAR","KWD","JOD","CLP","COP","PEN","ARS","UYU","CRC","PKR","BDT","LKR","EGP","NGN","TWD","KES","GHS","UGX","TZS","MAD","BWP","BGN","UAH","KZT","GEL","ISK","BHD","OMR"],"description":"Currency code","example":"USD"}},"required":["value","currency"],"description":"Expected placement fee"},"hireTarget":{"type":["integer","null"],"minimum":1,"maximum":10000,"description":"Number of positions to fill. Send null to clear","example":1},"visaSupport":{"type":["boolean","null"],"description":"Whether visa sponsorship is offered"},"public":{"type":"boolean","description":"Whether the project is publicly visible"},"location":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Display name / formatted address (e.g. \"London, UK\")","example":"London, UK"},"locality":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"City name","example":"London"},"region":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"State or region","example":"England"},"metro":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Metro area (People Data Labs convention, e.g. \"new york, new york\")","example":"new york, new york"},"country":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Country name","example":"United Kingdom"},"streetAddress":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 1","example":"123 Baker Street"},"addressLine2":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Street address line 2 (apartment, suite, unit, etc.)","example":"Suite 200"},"postalCode":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Postal / ZIP code","example":"NW1 6XE"},"raw":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Raw unstructured address text (used for geocoding)","example":"123 Baker Street, London NW1 6XE, United Kingdom"},"latitude":{"type":["number","null"],"minimum":-90,"maximum":90,"description":"Latitude coordinate (-90..90)","example":51.523767},"longitude":{"type":["number","null"],"minimum":-180,"maximum":180,"description":"Longitude coordinate (-180..180)","example":-0.158519}},"description":"Location"},"startedAt":{"type":["string","null"],"pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Planned or actual start date (YYYY-MM-DD). Must be no earlier than one calendar month ago. Stored as UTC midnight of that calendar day.","example":"2026-04-01"}}}}}},"responses":{"200":{"description":"Project updated","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Project ID"},"jobRole":{"type":"string","description":"Job role/title after the update"},"jobNumber":{"type":["string","null"],"description":"Job reference number"},"state":{"type":"string","description":"Project state","example":"lead"},"opportunityId":{"type":["string","null"],"format":"uuid","description":"Opportunity the project is linked to, null when it has none"},"companyContactIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"All hiring contact IDs linked to the project after the update"}},"required":["id","jobRole","jobNumber","state","opportunityId","companyContactIds"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Project, opportunity, or company contact not found / not in scope","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/{id}/stages":{"get":{"summary":"List project stages","description":"Use this endpoint to retrieve the recruitment pipeline configured for a specific project — every stage and the statuses within it.\n\nIn Atlas, a **stage** represents a step in the pipeline (e.g. Sourcing, Longlist, Shortlist, Offer) and each stage contains one or more **statuses** (e.g. \"Added\", \"Interested\", \"On Probation\"). Together they define how a candidate moves through a project.\n\nThis is the lightweight counterpart to **Get project details** when all you need is the pipeline shape — for example, to render a stage/status picker before adding or moving a candidate.\n\n**What you need to provide:**\nJust the project's ID in the URL path. Project IDs are UUIDs returned by the **List projects** endpoint.\n\n**Ordering:**\nStages are returned ordered by `position` ascending. Statuses within each stage are also ordered by `position` ascending.\n\n**What you get back:**\nFor each stage: its `id`, `name`, `type`, `phase`, `position`, and the array of nested `statuses`. Each status includes its `id`, `name`, `type`, and `position`.","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Project stages with nested statuses, ordered by position ascending","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Stage name","example":"Sourcing"},"type":{"type":"string","description":"Stage type","example":"sourcing"},"phase":{"type":"string","description":"Stage phase","example":"internal_selection"},"position":{"type":"integer","description":"Position in pipeline","example":0},"statuses":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Status name","example":"Added"},"type":{"type":"string","description":"Status type","example":"sourcing_added"},"position":{"type":"integer","description":"Position within stage","example":0}},"required":["id","name","type","position"]},"description":"Statuses within this stage"}},"required":["id","name","type","phase","position","statuses"]}}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/{id}/candidates":{"get":{"summary":"List candidates in a project","description":"Use this endpoint to retrieve a paginated list of candidates enrolled in a specific project, including both active and rejected candidates by default.\n\nIn Atlas, a **candidate** represents a person who has been added to a project's recruitment pipeline. Each candidate sits at a specific status within a specific stage of the pipeline (e.g. Sourcing → Added, Shortlist → Reviewed). This endpoint lets you read the current state of every candidate in a project, with the same ordering and filtering used by the Atlas web app candidate list.\n\n**What you can filter by:**\n- `phase` — comma-separated pipeline phase names (`internal_selection`, `presentation`, `first_round`, `late_stage`)\n- `stageId` — comma-separated CandidateStage UUIDs\n- `statusId` — comma-separated CandidateStatus UUIDs\n- `excludeRejected` — when `true`, candidates with a non-null rejection type are omitted\n\n**Ordering:**\nResults are sorted by pipeline position — first by stage position, then by status position. Use `sortDirection=asc` or `sortDirection=desc` (default `desc`) to control the order. This matches the in-app candidate list ordering.\n\n**Pagination:**\nUse `page` (1-based) and `pageSize` (1–100, default 25). The response includes a `pagination` object with `page`, `pageSize`, `total`, and `hasMore`.\n\n**What you get back:**\nEach candidate includes its ID, a nested `person` object (with `id`, name, and headline — use `person.id` with the People endpoints to fetch the person's full details, history, etc.), the current pipeline phase/stage/status, rejection state (`rejectedAt`, `rejectionReason`, `customRejectionReasonId`, `rejectionDetails` — where `rejectionReason` is the label of the agency-defined reason when one was picked, falling back to the predefined reason code), and timestamps. To find the available stage/status IDs for a project, call the **Get project details** endpoint first.\n\n**Deprecated fields:** `firstName`, `lastName`, `headlineRole`, and `headlineCompanyName` are still returned at the top level for backwards compatibility but are deprecated — use the equivalent fields inside `person` instead.","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Comma-separated pipeline phases to filter by","example":"internal_selection,presentation"},"required":false,"name":"phase","in":"query"},{"schema":{"type":"string","description":"Comma-separated CandidateStage UUIDs to filter by","example":"550e8400-e29b-41d4-a716-446655440002"},"required":false,"name":"stageId","in":"query"},{"schema":{"type":"string","description":"Comma-separated CandidateStatus UUIDs to filter by","example":"550e8400-e29b-41d4-a716-446655440003"},"required":false,"name":"statusId","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"When true, candidates with a non-null rejectionType are excluded","example":"true"},"required":false,"name":"excludeRejected","in":"query"},{"schema":{"type":"string","enum":["asc","desc"],"default":"desc","description":"Sort direction applied to pipeline position (stage, then status)","example":"desc"},"required":false,"name":"sortDirection","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (min: 1)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Results per page","example":25},"required":false,"name":"pageSize","in":"query"}],"responses":{"200":{"description":"Paginated list of candidates","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Candidate record ID"},"firstName":{"type":["string","null"],"description":"Deprecated — use `person.firstName` instead.","deprecated":true},"lastName":{"type":["string","null"],"description":"Deprecated — use `person.lastName` instead.","deprecated":true},"headlineRole":{"type":["string","null"],"description":"Deprecated — use `person.headlineRole` instead.","deprecated":true},"headlineCompanyName":{"type":["string","null"],"description":"Deprecated — use `person.headlineCompanyName` instead.","deprecated":true},"person":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Person ID (people.id)"},"firstName":{"type":["string","null"],"description":"Person first name"},"lastName":{"type":["string","null"],"description":"Person last name"},"headlineRole":{"type":["string","null"],"description":"Current/most recent job title"},"headlineCompanyName":{"type":["string","null"],"description":"Current/most recent company name"}},"required":["id","firstName","lastName","headlineRole","headlineCompanyName"],"description":"The person record for this candidate"},"stage":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Pipeline stage ID"},"name":{"type":"string","description":"Pipeline stage name","example":"Sourcing"},"phase":{"type":"string","enum":["internal_selection","presentation","first_round","late_stage"],"description":"Pipeline phase of the stage"}},"required":["id","name","phase"],"description":"Candidate's current pipeline stage"},"status":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Pipeline status ID"},"name":{"type":"string","description":"Pipeline status name","example":"Added"}},"required":["id","name"],"description":"Candidate's current pipeline status within the stage"},"rejectedAt":{"type":["string","null"],"description":"ISO 8601 — when the candidate was rejected, null if active"},"rejectionReason":{"type":["string","null"],"description":"Rejection reason, null if active. Returns the label of the agency-defined reason when one was picked (see `customRejectionReasonId`), otherwise the predefined reason code (e.g. `not_qualified`)","example":"Above budget"},"customRejectionReasonId":{"type":["string","null"],"format":"uuid","description":"ID of the agency-defined rejection reason recorded against the candidate, null when none was picked"},"rejectionDetails":{"type":["string","null"],"description":"Free-text rejection details, null when none were given"},"createdAt":{"type":"string","description":"ISO 8601 — when the candidate was added"},"updatedAt":{"type":"string","description":"ISO 8601 — when the candidate was last modified"}},"required":["id","firstName","lastName","headlineRole","headlineCompanyName","person","stage","status","rejectedAt","rejectionReason","customRejectionReasonId","rejectionDetails","createdAt","updatedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Results per page","example":25},"total":{"type":"integer","description":"Total number of matching candidates","example":42},"hasMore":{"type":"boolean","description":"Whether more pages of results are available","example":true}},"required":["page","pageSize","total","hasMore"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Create a candidate","description":"Use this endpoint to add a person from your Atlas contacts into a specific project as a candidate - moving them into the recruitment pipeline for that role.\n\nFor example, if you receive a job application through your website and have already added the person to Atlas (using the People endpoints), you can use this endpoint to automatically add them as a candidate to the relevant project, at the right stage of the pipeline.\n\n**What you need to provide:**\n- The **project ID** in the URL path - the project you want to add them to\n- The **person ID** in the request body - the Atlas ID of the person you are adding as a candidate\n- Optionally, a **statusId** or **stageId** to control where in the pipeline they are placed (see below)\n\n**How pipeline placement works:**\n\nEvery project in Atlas has a pipeline made up of stages (e.g. Sourcing, Longlist, Shortlist) and within each stage there are statuses (e.g. Added, Reviewed, Approved). When you add a candidate, you can control exactly where they land:\n\n- **If you provide a `statusId`** - the candidate is placed at that exact status. The `stageId` is ignored if both are provided.\n- **If you provide only a `stageId`** - the candidate is placed at the first status within that stage.\n- **If you provide neither** - the candidate is placed at the very first status of the very first stage (the default starting point).\n\nTo find the correct `statusId` or `stageId` for a project, first call the **Get project details** endpoint - it returns the full pipeline with all stage and status IDs.\n\n**What you get back:**\nThe newly created candidate record, including the candidate ID, person ID, project ID, and the status they were placed at.","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCandidatePayload"}}}},"responses":{"201":{"description":"Candidate created","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Candidate ID"},"personId":{"type":"string","format":"uuid","description":"Person ID"},"projectId":{"type":"string","format":"uuid","description":"Project ID"},"statusId":{"type":"string","format":"uuid","description":"Assigned status ID"}},"required":["id","personId","projectId","statusId"]}}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Person, project, status, or stage not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/{projectId}/candidates/{candidateId}":{"get":{"summary":"Get a candidate in a project","description":"Use this endpoint to retrieve the current pipeline position of a single candidate within a specific project — what stage and status they sit at, who owns the candidate record, whether they have been rejected (and why), and a snapshot of the person and project they relate to.\n\nIn Atlas, a **candidate** represents a person who has been added to a project's recruitment pipeline. Each candidate sits at a specific status within a specific stage of the pipeline (e.g. Sourcing → Added, Shortlist → Reviewed). This endpoint returns the full state of one candidate by its ID, scoped to the project provided in the URL.\n\n**What you need to provide:**\n- The **project ID** in the URL path — the project the candidate belongs to\n- The **candidate ID** in the URL path — the candidate record to look up\n\nBoth IDs are UUIDs returned by the **List projects** and **List candidates in a project** endpoints respectively.\n\n**What you get back:**\n- `id` — the candidate record ID\n- `stage` — the candidate's current pipeline stage (`id`, `name`, `phase`)\n- `status` — the candidate's current status within the stage (`id`, `name`)\n- `owner` — the user who owns the candidate record (`id`, `name`, `email`); this is the candidate's owner, not necessarily the project's owner\n- `person` — a snapshot of the underlying person (`id`, `firstName`, `lastName`, `headlineRole`, `headlineCompanyName`); use `person.id` with the People endpoints to fetch the person's full details\n- `project` — a snapshot of the project the candidate is in (`id`, `jobRole`, `state`)\n- `rejectedAt` / `rejectionReason` / `customRejectionReasonId` / `rejectionDetails` — populated when the candidate has been rejected, otherwise `null`. `rejectionReason` returns the label of the agency-defined reason when one was picked (the same label the Atlas app shows), and falls back to the predefined reason code (e.g. `not_qualified`) for rejections recorded before agency-defined reasons existed\n\n**Returns `404` when:**\n- The candidate does not exist, does not belong to the specified project, or is in a different agency.","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"projectId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Candidate ID","example":"550e8400-e29b-41d4-a716-446655440004"},"required":true,"name":"candidateId","in":"path"}],"responses":{"200":{"description":"Candidate detail within the specified project","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Candidate record ID"},"stage":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Pipeline stage ID"},"name":{"type":"string","description":"Pipeline stage name","example":"Sourcing"},"phase":{"type":"string","enum":["internal_selection","presentation","first_round","late_stage"],"description":"Pipeline phase of the stage"}},"required":["id","name","phase"],"description":"Candidate's current pipeline stage"},"status":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Pipeline status ID"},"name":{"type":"string","description":"Pipeline status name","example":"Added"}},"required":["id","name"],"description":"Candidate's current pipeline status within the stage"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Owner user ID"},"name":{"type":"string","description":"Owner name","example":"Jane Smith"},"email":{"type":"string","description":"Owner email","example":"owner@agency.com"}},"required":["id","name","email"],"description":"User who owns this candidate record"},"person":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Person ID (people.id)"},"firstName":{"type":["string","null"],"description":"Person first name"},"lastName":{"type":["string","null"],"description":"Person last name"},"headlineRole":{"type":["string","null"],"description":"Current/most recent job title"},"headlineCompanyName":{"type":["string","null"],"description":"Current/most recent company name"}},"required":["id","firstName","lastName","headlineRole","headlineCompanyName"],"description":"The person record for this candidate"},"project":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Project ID"},"jobRole":{"type":"string","description":"Project job role/title","example":"Software Engineer"},"state":{"type":"string","enum":["active","closed","on_hold","talent_pool","lead","pitch","opportunity"],"description":"Project state","example":"active"}},"required":["id","jobRole","state"],"description":"The project the candidate is in"},"rejectedAt":{"type":["string","null"],"description":"ISO 8601 — when the candidate was rejected, null if active"},"rejectionReason":{"type":["string","null"],"description":"Rejection reason, null if active. Returns the label of the agency-defined reason when one was picked (see `customRejectionReasonId`), otherwise the predefined reason code (e.g. `not_qualified`)","example":"Above budget"},"customRejectionReasonId":{"type":["string","null"],"format":"uuid","description":"ID of the agency-defined rejection reason recorded against the candidate, null when none was picked"},"rejectionDetails":{"type":["string","null"],"description":"Free-text rejection details, null when none were given"}},"required":["id","stage","status","owner","person","project","rejectedAt","rejectionReason","customRejectionReasonId","rejectionDetails"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Candidate not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/{projectId}/candidates/{candidateId}/move":{"patch":{"summary":"Move a candidate to a different stage or status","description":"Move an existing candidate to a different stage or status within the same project pipeline.\n\n**How pipeline placement works:**\n\n- **If you provide a `statusId`** - the candidate is moved to that exact status. `stageId` is ignored if both are provided.\n- **If you provide only a `stageId`** - the candidate is moved to the first status within that stage.\n\nTo find the correct `statusId` or `stageId` for a project, call the **Get project details** endpoint - it returns the full pipeline with all stage and status IDs.\n\n**Returns `422` when:**\n- The project is closed\n- The candidate has been rejected (the rejection must be rolled back first)","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"projectId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Candidate ID","example":"550e8400-e29b-41d4-a716-446655440004"},"required":true,"name":"candidateId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"statusId":{"type":"string","format":"uuid","description":"Target status ID. When provided, stageId is ignored","example":"550e8400-e29b-41d4-a716-446655440002"},"stageId":{"type":"string","format":"uuid","description":"Target stage ID — candidate is moved to the first status of this stage. Ignored when statusId is provided","example":"550e8400-e29b-41d4-a716-446655440003"}}}}}},"responses":{"200":{"description":"Candidate moved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Candidate ID"},"personId":{"type":"string","format":"uuid","description":"Person ID"},"projectId":{"type":"string","format":"uuid","description":"Project ID"},"statusId":{"type":"string","format":"uuid","description":"Resolved target status ID"}},"required":["id","personId","projectId","statusId"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Candidate, stage, or status not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/{projectId}/candidates/{candidateId}/reject":{"patch":{"summary":"Reject a candidate","description":"Reject a candidate in a project pipeline. The candidate keeps their current stage and status but is marked as rejected, and any active outreach campaigns for the candidate are stopped.\n\n**What you need to provide:**\n\n- **`rejectionType`** (required) - who rejected the candidate: `by_us` (the agency), `by_client`, or `self` (the candidate withdrew).\n- **`rejectionReason`** (optional) - a predefined reason code.\n- **`customRejectionReasonId`** (optional) - the ID of one of your agency's custom rejection reasons. Deactivated reasons are rejected with `404`.\n- **`rejectionDetails`** (optional) - free-text notes about the rejection (max 512 characters).\n\nOptional fields also accept an explicit `null`, which is treated the same as omitting the field.\n\n**Returns `422` when:**\n- The project is closed\n- The candidate is already rejected\n- The candidate sits on a legacy pipeline status that does not support rejection\n\nTo move a rejected candidate again, the rejection must be rolled back first (currently only possible in the Atlas app).","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"projectId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Candidate ID","example":"550e8400-e29b-41d4-a716-446655440004"},"required":true,"name":"candidateId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"rejectionType":{"type":"string","enum":["by_us","by_client","self"],"description":"Who rejected the candidate: by_us (the agency), by_client, or self (candidate withdrew)","example":"by_us"},"rejectionReason":{"type":["string","null"],"enum":["above_budget","accepted_another_offer","cultural_fit","did_not_attend_the_interview","not_available","not_qualified","other","overqualified","reference_check_failed","rejected_the_offer","technical_test_failed","unresponsive","location","rejected_by_us_or_client","poor_communication","no_right_to_work","lacks_primary_skillset","not_enough_experience","doesnt_have_qualification","position_was_closed","went_with_another_provider","went_cold","too_expensive","not_using_agencies","not_interested","not_currently_hiring","no_response","no_reason","decided_not_to_hire"],"description":"Predefined rejection reason. Optional — omit or send null, e.g. for self rejections","example":"not_qualified"},"customRejectionReasonId":{"type":["string","null"],"format":"uuid","description":"ID of an agency-defined custom rejection reason (must be active). May be combined with rejectionReason. Omit or send null to skip","example":"550e8400-e29b-41d4-a716-446655440005"},"rejectionDetails":{"type":["string","null"],"maxLength":512,"description":"Free-text details about the rejection (max 512 characters). Omit or send null to skip","example":"Salary expectations above the approved band"}},"required":["rejectionType"]}}}},"responses":{"200":{"description":"Candidate rejected","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Candidate ID"},"personId":{"type":"string","format":"uuid","description":"Person ID"},"projectId":{"type":"string","format":"uuid","description":"Project ID"},"statusId":{"type":"string","format":"uuid","description":"Pipeline status the candidate was in when rejected"},"rejectionType":{"type":"string","description":"Who rejected the candidate","example":"by_us"},"rejectionReason":{"type":["string","null"],"description":"Predefined rejection reason","example":"not_qualified"},"customRejectionReasonId":{"type":["string","null"],"format":"uuid","description":"Agency-defined custom rejection reason ID"},"rejectionDetails":{"type":["string","null"],"description":"Free-text rejection details"},"rejectedAt":{"type":"string","description":"When the rejection was recorded (ISO 8601, UTC)","example":"2026-07-09T12:00:00.000Z"}},"required":["id","personId","projectId","statusId","rejectionType","rejectionReason","customRejectionReasonId","rejectionDetails","rejectedAt"]}},"required":["status","data"]}}}},"400":{"description":"Bad request - the request body is not valid JSON","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message"},"description":{"type":"string","description":"Details of the JSON parse failure"}},"required":["error","description"]},"example":{"error":"Bad Request. The JSON you sent is not valid.","description":"Unexpected end of JSON input"}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Project, candidate, or rejection reason not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/{id}/applicants":{"post":{"summary":"Create an applicant","description":"Use this endpoint to create an applicant on a project from an uploaded resume file.\n\nUnlike candidates (where you add an existing person to a project), applicants are created from a resume file that has been uploaded via the Files endpoint. Atlas will automatically parse the resume and score the applicant against project criteria.\n\n**What you need to provide:**\n- The **project ID** in the URL path\n- A **file_id** in the request body - the ID of a previously uploaded resume file (must be type \"resume\")\n- Optionally, applicant details (name, email, phone, etc.) - if omitted, these are extracted from the resume\n\nTo get a file ID, first upload the resume using the **Upload a file** endpoint.","tags":["Projects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"fileId":{"type":"string","format":"uuid","description":"ID of the uploaded file (must be type \"resume\")","example":"550e8400-e29b-41d4-a716-446655440000"},"firstName":{"type":["string","null"],"minLength":1,"description":"First name","example":"Jane"},"lastName":{"type":["string","null"],"minLength":1,"description":"Last name","example":"Doe"},"email":{"type":["string","null"],"format":"email","description":"Email address","example":"jane.doe@example.com"},"phone":{"type":["string","null"],"minLength":1,"description":"Phone number","example":"+442071234567"},"currentRole":{"type":["string","null"],"minLength":1,"description":"Current job title","example":"Software Engineer"},"currentEmployer":{"type":["string","null"],"minLength":1,"description":"Current employer","example":"Acme Corp"},"source":{"type":"string","enum":["form","manual_upload","api"],"description":"Applicant source","example":"api"},"notes":{"type":["string","null"],"minLength":1,"description":"Free-text notes","example":"Referred by internal team"}},"required":["fileId"]}}}},"responses":{"201":{"description":"Applicant created","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Applicant ID"},"project_id":{"type":"string","format":"uuid","description":"Project ID"},"status":{"type":"string","description":"Applicant status","example":"pending"},"first_name":{"type":["string","null"],"description":"First name"},"last_name":{"type":["string","null"],"description":"Last name"},"email":{"type":["string","null"],"description":"Email address"},"phone":{"type":["string","null"],"description":"Phone number"},"current_role":{"type":["string","null"],"description":"Current job title"},"current_employer":{"type":["string","null"],"description":"Current employer"},"source":{"type":"string","description":"Applicant source","example":"api"},"notes":{"type":["string","null"],"description":"Notes"},"file_id":{"type":["string","null"],"format":"uuid","description":"Resume file ID"},"created_at":{"type":"string","format":"date-time","description":"Creation timestamp"}},"required":["id","project_id","status","first_name","last_name","email","phone","current_role","current_employer","source","notes","file_id","created_at"]}},"required":["status","data"]}}}},"400":{"description":"Bad request - file is not of type resume","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"File type must be \"resume\""}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Project or file not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/public/{agencyId}/projects":{"get":{"summary":"List public projects","description":"Use this endpoint to retrieve a paginated list of public projects for an agency without authentication.\n\nThis endpoint is limited to projects where `public` is `true` and `state` is `active`. It is intended for use in public-facing integrations such as career pages or job boards, where you want to show a list of open roles.\n\nEach item in the list has the same shape as the response from **Get public project details** — so you can render a full role card from the list response without making an extra request per project. This includes the `owner` (the lead consultant on the role, as `{ userId, name, email }`) so you can display contact details next to each job; `owner` is `null` when the project has no owner, or when the recorded owner does not belong to the project's agency.\n\n**What you need to provide:**\n- The **agency ID** in the URL path - the agency whose public projects you want to list\n\n**Incremental sync:**\nPass `updatedAfter` with an ISO 8601 timestamp to receive only projects whose `updatedAt` is later than that timestamp. Each returned item includes `updatedAt`; persist the largest value you see and pass it on the next call as your sync cursor.\n\n**Pagination:**\nResults are returned in pages. Use the `page` and `pageSize` query parameters to move through large result sets. The response includes a `pagination` object so you know how many results exist in total.","tags":["Jobs Portal"],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Agency ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"agencyId","in":"path"},{"schema":{"type":"string","format":"date-time","description":"Only projects updated after this ISO 8601 timestamp (inclusive)","example":"2026-06-04T10:00:00Z"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of public projects","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"jobRole":{"type":"string","description":"Job role/title","example":"Software Engineer"},"jobNumber":{"type":["string","null"],"description":"Job reference number"},"state":{"type":"string","enum":["active","closed","on_hold","talent_pool","lead","pitch","opportunity"],"description":"Project state","example":"active"},"jobDescription":{"type":["string","null"],"description":"Plain text job description"},"jobDescriptionFormatted":{"type":["string","null"],"description":"Job description in formatted HTML"},"contractType":{"type":["string","null"],"enum":["full_time","part_time","contract","non_exec"],"description":"Contract type"},"workMode":{"type":["string","null"],"enum":["office","hybrid","remote"],"description":"Work mode"},"seniority":{"type":["string","null"],"enum":["partner","board","founder","cxo","vp","director","manager","senior","middle","junior"],"description":"Seniority level"},"func":{"type":["string","null"],"description":"Job function"},"hireTarget":{"type":["integer","null"],"description":"Target number of hires","example":1},"visaSupport":{"type":["boolean","null"],"description":"Whether visa support is offered"},"salary":{"type":["string","null"],"description":"Salary or salary range"},"salaryCurrency":{"type":["string","null"],"description":"Salary currency code","example":"USD"},"location":{"type":["object","null"],"properties":{"city":{"type":["string","null"],"description":"City/locality","example":"London"},"region":{"type":["string","null"],"description":"Region/state","example":"England"},"country":{"type":["string","null"],"description":"Country","example":"United Kingdom"},"formattedAddress":{"type":["string","null"],"description":"Full formatted address","example":"London, England, United Kingdom"}},"required":["city","region","country","formattedAddress"],"description":"Project location"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Custom attribute values for this project"},"createdAt":{"type":["string","null"],"description":"ISO 8601 creation date"},"startedAt":{"type":["string","null"],"description":"Start date (YYYY-MM-DD)","example":"2026-03-23"},"owner":{"type":["object","null"],"properties":{"userId":{"type":"string","format":"uuid","description":"Owner user ID"},"email":{"type":"string","description":"Owner email","example":"owner@agency.com"},"name":{"type":"string","description":"Owner name","example":"Jane Smith"}},"required":["userId","email","name"],"description":"Project owner (lead consultant), or null when the project has no owner or the recorded owner does not belong to the project's agency"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 timestamp of the project's last modification. Persist the largest value seen across a response as the next sync cursor for `updatedAfter`.","example":"2026-06-04T10:00:00.000Z"}},"required":["id","jobRole","jobNumber","state","jobDescription","jobDescriptionFormatted","contractType","workMode","seniority","func","hireTarget","visaSupport","salary","salaryCurrency","location","customAttributes","createdAt","startedAt","owner","updatedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":42},"totalPages":{"type":"integer","description":"Total number of pages","example":2}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/public/{agencyId}/projects/{id}":{"get":{"summary":"Get public project details","description":"Use this endpoint to retrieve details of a public project without authentication.\n\nThis endpoint is limited to projects where `public` is `true`. It is intended for use in public-facing integrations such as career pages or job boards.\n\nThe response includes the `owner` — the lead consultant on the role, as `{ userId, name, email }` — so you can display contact details on the job page. `owner` is `null` when the project has no owner, or when the recorded owner does not belong to the project's agency.\n\nCompared to the authenticated **Get project details** endpoint, this response does not include `company`, `members`, or `stages`.\n\n**What you need to provide:**\n- The **agency ID** in the URL path - the agency the project belongs to\n- The **project ID** in the URL path - the project you want to retrieve\n\nBoth IDs are UUIDs. If the project does not exist, does not belong to the specified agency, or is not marked as public, a 404 response is returned.","tags":["Jobs Portal"],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Agency ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"agencyId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Project ID","example":"550e8400-e29b-41d4-a716-446655440001"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Public project details","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"jobRole":{"type":"string","description":"Job role/title","example":"Software Engineer"},"jobNumber":{"type":["string","null"],"description":"Job reference number"},"state":{"type":"string","enum":["active","closed","on_hold","talent_pool","lead","pitch","opportunity"],"description":"Project state","example":"active"},"jobDescription":{"type":["string","null"],"description":"Plain text job description"},"jobDescriptionFormatted":{"type":["string","null"],"description":"Job description in formatted HTML"},"contractType":{"type":["string","null"],"enum":["full_time","part_time","contract","non_exec"],"description":"Contract type"},"workMode":{"type":["string","null"],"enum":["office","hybrid","remote"],"description":"Work mode"},"seniority":{"type":["string","null"],"enum":["partner","board","founder","cxo","vp","director","manager","senior","middle","junior"],"description":"Seniority level"},"func":{"type":["string","null"],"description":"Job function"},"hireTarget":{"type":["integer","null"],"description":"Target number of hires","example":1},"visaSupport":{"type":["boolean","null"],"description":"Whether visa support is offered"},"salary":{"type":["string","null"],"description":"Salary or salary range"},"salaryCurrency":{"type":["string","null"],"description":"Salary currency code","example":"USD"},"location":{"type":["object","null"],"properties":{"city":{"type":["string","null"],"description":"City/locality","example":"London"},"region":{"type":["string","null"],"description":"Region/state","example":"England"},"country":{"type":["string","null"],"description":"Country","example":"United Kingdom"},"formattedAddress":{"type":["string","null"],"description":"Full formatted address","example":"London, England, United Kingdom"}},"required":["city","region","country","formattedAddress"],"description":"Project location"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Custom attribute values for this project"},"createdAt":{"type":["string","null"],"description":"ISO 8601 creation date"},"startedAt":{"type":["string","null"],"description":"Start date (YYYY-MM-DD)","example":"2026-03-23"},"owner":{"type":["object","null"],"properties":{"userId":{"type":"string","format":"uuid","description":"Owner user ID"},"email":{"type":"string","description":"Owner email","example":"owner@agency.com"},"name":{"type":"string","description":"Owner name","example":"Jane Smith"}},"required":["userId","email","name"],"description":"Project owner (lead consultant), or null when the project has no owner or the recorded owner does not belong to the project's agency"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 timestamp of the project's last modification. Persist the largest value seen across a response as the next sync cursor for `updatedAfter`.","example":"2026-06-04T10:00:00.000Z"}},"required":["id","jobRole","jobNumber","state","jobDescription","jobDescriptionFormatted","contractType","workMode","seniority","func","hireTarget","visaSupport","salary","salaryCurrency","location","customAttributes","createdAt","startedAt","owner","updatedAt"]}},"required":["status","data"]}}}},"404":{"description":"Project not found or not public","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/candidates":{"get":{"summary":"List candidates","description":"Use this endpoint to retrieve a paginated list of candidates across **all** projects in your agency.\n\nIn Atlas, a **candidate** represents a person who has been added to a project's recruitment pipeline. The same person can be a candidate on several projects at once, so each item in this list is one *candidacy* — a person at a specific status within a specific project's pipeline. Unlike **List candidates in a project**, this endpoint is not scoped to a single project, which makes it the right choice for syncing or reporting on candidates agency-wide.\n\n**What you can filter by:**\n- `personId` — return every candidacy for a single person, across all of their projects\n- `createdAfter` / `createdBefore` — only candidacies added within a date range (inclusive). Accepts an ISO 8601 datetime or a date-only `YYYY-MM-DD` value\n- `updatedAfter` / `updatedBefore` — only candidacies last modified within a date range (inclusive). Use `updatedAfter` as an incremental-sync cursor: persist the largest `updatedAt` you receive and pass it back on the next run\n- `includeDeleted` — when `true`, soft-deleted candidacies are included as tombstones (with a populated `deletedAt`) so you can sync deletions\n\n**Ordering:**\nResults are ordered by candidate creation date (`createdAt`, then `id` as a tiebreaker). Use `sortDirection=asc` or `sortDirection=desc` (default `desc`, newest first) to control the order.\n\n**Pagination:**\nUse `page` (1-based) and `pageSize` (1–100, default 25). The response includes a `pagination` object with `page`, `pageSize`, `total`, and `totalPages`.\n\n**What you get back:**\nEach candidacy includes its ID, a nested `person` (id, name, headline, and contact `identities`), the `project` it belongs to, the current pipeline `stage` and `status`, the `owner`, `rejection` state (null when active), and timestamps. Use `person.id` with the People endpoints to fetch a person's full details.","tags":["Candidates"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Filter to candidacies for a single person (across every project they are a candidate in)","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"personId","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only candidates created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only candidates created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only candidates updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only candidates updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted candidates as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"string","enum":["asc","desc"],"default":"desc","description":"Sort direction by candidate creation date (createdAt, then id). Defaults to desc (newest first).","example":"desc"},"required":false,"name":"sortDirection","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of candidates","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Candidate record ID"},"person":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Person ID (people.id)"},"firstName":{"type":["string","null"],"description":"Person first name"},"lastName":{"type":["string","null"],"description":"Person last name"},"headlineRole":{"type":["string","null"],"description":"Current/most recent job title"},"headlineCompanyName":{"type":["string","null"],"description":"Current/most recent company name"},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["email","phone","linkedin","website"],"description":"Identity type"},"value":{"type":"string","description":"Identity value (email address, phone number, LinkedIn URL, …)"},"isPersonal":{"type":"boolean","description":"Whether this is a personal (vs. work) identity"},"isPrimary":{"type":"boolean","description":"Whether this is the favourited / primary identity of its type"}},"required":["type","value","isPersonal","isPrimary"]},"description":"Active, non-hidden contact identities (email / phone / LinkedIn)"}},"required":["id","firstName","lastName","headlineRole","headlineCompanyName","identities"],"description":"The person record for this candidate"},"project":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Project ID"},"jobRole":{"type":["string","null"],"description":"Job role of the project"},"state":{"type":"string","description":"Project state","example":"active"}},"required":["id","jobRole","state"],"description":"The project this candidacy belongs to"},"stage":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Pipeline stage ID"},"name":{"type":"string","description":"Pipeline stage name","example":"Sourcing"},"phase":{"type":"string","enum":["internal_selection","presentation","first_round","late_stage","lead_generation","prospect","early_interest","proposal_and_negotiation","closed","client","all_contacts","won"],"description":"Pipeline phase of the stage"}},"required":["id","name","phase"],"description":"Candidate's current pipeline stage"},"status":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Pipeline status ID"},"name":{"type":"string","description":"Pipeline status name","example":"Added"}},"required":["id","name"],"description":"Candidate's current pipeline status within the stage"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Owning user ID"},"name":{"type":["string","null"],"description":"Owning user name"},"email":{"type":["string","null"],"description":"Owning user email"}},"required":["id","name","email"],"description":"The user who owns this candidate record"},"rejection":{"type":["object","null"],"properties":{"type":{"type":["string","null"],"description":"Rejection type"},"reason":{"type":["string","null"],"description":"Rejection reason"},"details":{"type":["string","null"],"description":"Free-text rejection details"},"rejectedAt":{"type":["string","null"],"description":"ISO 8601 — when the candidate was rejected"},"rejectedById":{"type":["string","null"],"format":"uuid","description":"User ID who rejected the candidate"}},"required":["type","reason","details","rejectedAt","rejectedById"],"description":"Rejection state — null when the candidate is active"},"createdAt":{"type":["string","null"],"description":"ISO 8601 — when the candidate was added"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 — when the candidate was last modified"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 — when the candidate was soft-deleted, null unless returned as a tombstone"}},"required":["id","person","project","stage","status","owner","rejection","createdAt","updatedAt","deletedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Results per page","example":25},"total":{"type":"integer","description":"Total number of matching candidates","example":42},"totalPages":{"type":"integer","description":"Total number of pages","example":2}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/candidate-stage-events":{"get":{"summary":"List candidate stage transitions","description":"Returns a keyset-paginated, agency-wide feed of candidate stage transitions (a candidate moving between pipeline stages, e.g. \"Interviewing\" -> \"Offered\"), oldest first. Each row records the from/to stage (id and name), the candidacy with its person (name), the project with its company, the user who moved it (name and email), and when. Nested references are resolved even when the underlying record was later deleted; a reference is null only when it could not be resolved.\n\n**Pagination (keyset):** Responses carry `pagination.hasMore` and `pagination.nextCursor` rather than a page number or total count. Request the first page without a cursor, then send the returned `nextCursor.cursorDate`/`cursorId` on each subsequent request. This stays O(pageSize) at any depth, so it is safe for large historical backfills.\n\n**Backfill:** Loop the cursor until `hasMore` is `false`; optionally bound with `createdAfter` to cap the horizon.\n\n**Incremental sync:** Poll with `createdAfter` (the max `movedAt` from the previous run) and page the cursor within each poll. Overlap slightly and dedupe by `id` so a row written exactly at the boundary is never missed.\n\n**Access:** Requires a key with BOTH `Candidates` and `Users` read scopes, since each row is enriched with the acting user (name and email).\n\n**Notes:** Reverted moves (backward-move corrections) are excluded unless `includeReverted=true`. `stageFrom` is null on the first move into the pipeline. This feed is eventually consistent (populated asynchronously) and is not emitted for talent-pool or lead projects.","tags":["Candidate stage events"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only candidate stage events created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only candidate stage events created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include stage moves that were later reverted (backward-move corrections). Defaults to false, so only effective transitions are returned.","example":"false"},"required":false,"name":"includeReverted","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Items per page (max 100).","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Keyset pagination cursor: the `pagination.nextCursor.cursorDate` returned by the previous request. Send together with `cursorId` to fetch the next page in stable order that stays O(pageSize) at any depth. Omit for the first page.","example":"2026-01-05T12:00:00.000Z"},"required":false,"name":"cursorDate","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Keyset pagination cursor: the `pagination.nextCursor.cursorId` returned by the previous request. Must be sent together with `cursorDate`.","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"cursorId","in":"query"}],"responses":{"200":{"description":"Paginated candidate stage transitions","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Stable identifier for this stage-move event."},"type":{"type":"string","enum":["candidate_stage_moved"]},"candidate":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The candidacy (candidate row on a project) this move belongs to."},"person":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"],"example":"Michael"},"lastName":{"type":["string","null"],"example":"Scott"}},"required":["id","firstName","lastName"],"description":"The person behind the candidacy. Null if the person could not be resolved."}},"required":["id","person"],"description":"The candidacy that moved, with its person."},"project":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"jobRole":{"type":"string","description":"The project job role / title.","example":"Senior Engineer"},"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Acme Corp"}},"required":["id","name"],"description":"Company the project belongs to, when set."}},"required":["id","jobRole","company"],"description":"Project the candidacy belongs to, with its company. Null when the move carried no project."},"stageFrom":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"],"description":"Stage name (e.g. \"Interviewing\"). Null if the stage could not be resolved (e.g. it was deleted).","example":"Interviewing"}},"required":["id","name"],"description":"Stage the candidate moved from. Null on the first move into the pipeline."},"stageTo":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"],"description":"Stage name (e.g. \"Interviewing\"). Null if the stage could not be resolved (e.g. it was deleted).","example":"Interviewing"}},"required":["id","name"],"description":"Stage the candidate moved to."},"movedBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Jane Recruiter"},"email":{"type":"string","example":"jane@agency.com"}},"required":["id","name","email"],"description":"User who performed the move, when known (null for system/automated moves)."},"movedAt":{"type":"string","description":"UTC ISO 8601 time the move occurred. Use this value as the cursor horizon for `createdAfter` polling."},"isReverted":{"type":"boolean","description":"Whether this move was later reverted. Only present when `includeReverted=true`; otherwise always false."}},"required":["id","type","candidate","project","stageFrom","stageTo","movedBy","movedAt","isReverted"]}},"pagination":{"type":"object","properties":{"pageSize":{"type":"integer","example":25},"hasMore":{"type":"boolean","description":"Whether more rows exist beyond this page. `true` means at least one further page is available — advance the `nextCursor` until it is `false`.","example":false},"nextCursor":{"type":["object","null"],"properties":{"cursorDate":{"type":"string","description":"Pass back as `cursorDate` to fetch the next page."},"cursorId":{"type":"string","format":"uuid","description":"Pass back as `cursorId` to fetch the next page."}},"required":["cursorDate","cursorId"],"description":"Keyset cursor for the next page. Non-null when `hasMore` is `true` — send `cursorDate`/`cursorId` on the next request to page in stable order that stays O(pageSize) at any depth."}},"required":["pageSize","hasMore","nextCursor"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/spec-cv-events":{"get":{"summary":"List spec CV sends","description":"Returns a keyset-paginated, agency-wide feed of speculative (\"spec\") CV sends (`spec_cv_sent`), oldest first — one row each time a candidate CV is emailed to a contact, whether via a direct Float send or a spec campaign email step. Each row records the candidate (person id and name), the contact it went to, the opportunity it belongs to (id and name), the user who sent it (name and email), the spec campaign id when applicable, and when. Nested references are resolved even when the underlying record was later deleted; a reference is null only when it could not be resolved.\n\n**Pagination (keyset):** Responses carry `pagination.hasMore` and `pagination.nextCursor` rather than a page number or total count. Request the first page without a cursor, then send the returned `nextCursor.cursorDate`/`cursorId` on each subsequent request. This stays O(pageSize) at any depth, so it is safe for large historical backfills.\n\n**Backfill:** Loop the cursor until `hasMore` is `false`; optionally bound with `createdAfter` to cap the horizon.\n\n**Incremental sync:** Poll with `createdAfter` (the max `sentAt` from the previous run) and page the cursor within each poll. Overlap slightly and dedupe by `id` so a row written exactly at the boundary is never missed.\n\n**Dual-sync with `/api/v1/prospects`:** Rows exist only from the feature go-live onward — spec CVs floated before that date have no event here. For a complete picture, backfill the legacy state from `GET /api/v1/prospects` first, then poll this feed with `createdAfter` for increments.\n\n**Access:** Requires a key with BOTH `Prospects` and `Users` read scopes, since each row is enriched with the sending user (name and email).\n\n**Notes:** Reverted sends (e.g. the speculative candidate was deleted) are excluded unless `includeReverted=true`. Repeat sends of the same candidate to the same contact on one opportunity are deduplicated at emit time, so they do not produce extra rows. This feed is eventually consistent (populated asynchronously).","tags":["Spec CV events"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only spec CV events created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only spec CV events created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include sends that were later reverted (e.g. the speculative candidate was deleted). Defaults to false, so only effective sends are returned.","example":"false"},"required":false,"name":"includeReverted","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Items per page (max 100).","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Keyset pagination cursor: the `pagination.nextCursor.cursorDate` returned by the previous request. Send together with `cursorId` to fetch the next page in stable order that stays O(pageSize) at any depth. Omit for the first page.","example":"2026-01-05T12:00:00.000Z"},"required":false,"name":"cursorDate","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Keyset pagination cursor: the `pagination.nextCursor.cursorId` returned by the previous request. Must be sent together with `cursorDate`.","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"cursorId","in":"query"}],"responses":{"200":{"description":"Paginated spec CV sends","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Stable identifier for this spec CV send event."},"type":{"type":"string","enum":["spec_cv_sent"]},"candidate":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"],"example":"Michael"},"lastName":{"type":["string","null"],"example":"Scott"}},"required":["id","firstName","lastName"],"description":"The candidate (person) whose CV was sent. Null if the person could not be resolved."},"contact":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"],"example":"Michael"},"lastName":{"type":["string","null"],"example":"Scott"}},"required":["id","firstName","lastName"],"description":"The contact (person) the CV was sent to. Null if the person could not be resolved."},"opportunity":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"The opportunity name.","example":"Connecting with Acme Corp"}},"required":["id","name"],"description":"The opportunity the send belongs to. Null if it could not be resolved."},"sentBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Jane Recruiter"},"email":{"type":"string","example":"jane@agency.com"}},"required":["id","name","email"],"description":"User who sent the spec CV, when known (null for system/automated sends)."},"sentAt":{"type":"string","description":"UTC ISO 8601 time the spec CV was sent. Use this value as the cursor horizon for `createdAfter` polling."},"campaignId":{"type":["string","null"],"format":"uuid","description":"The spec campaign the send came from. Null for direct Float sends."},"isReverted":{"type":"boolean","description":"Whether this send was later reverted (e.g. the speculative candidate was deleted). Only present when `includeReverted=true`; otherwise always false."}},"required":["id","type","candidate","contact","opportunity","sentBy","sentAt","campaignId","isReverted"]}},"pagination":{"type":"object","properties":{"pageSize":{"type":"integer","example":25},"hasMore":{"type":"boolean","description":"Whether more rows exist beyond this page. `true` means at least one further page is available — advance the `nextCursor` until it is `false`.","example":false},"nextCursor":{"type":["object","null"],"properties":{"cursorDate":{"type":"string","description":"Pass back as `cursorDate` to fetch the next page."},"cursorId":{"type":"string","format":"uuid","description":"Pass back as `cursorId` to fetch the next page."}},"required":["cursorDate","cursorId"],"description":"Keyset cursor for the next page. Non-null when `hasMore` is `true` — send `cursorDate`/`cursorId` on the next request to page in stable order that stays O(pageSize) at any depth."}},"required":["pageSize","hasMore","nextCursor"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/users":{"get":{"summary":"List active users","description":"Use this endpoint to retrieve a list of all active users (consultants) in your Atlas account.\n\nIn Atlas, a **user** is a member of your team who has a login - for example, a recruiter, a researcher, or an admin. This endpoint is useful when you need to look up a user's ID before using it in another API call - for example, when creating a note on a person (which requires the ID of the user the note is attributed to), or when filtering projects by owner or member.\n\n**What you can filter by:**\n- `email` - pass an email address to find a specific user. This is the most common use case: if you know the email address of a consultant, you can use this endpoint to look up their Atlas user ID.\n\n**Pagination:**\nResults are returned in pages. The response includes a `pagination` object telling you the current page, how many results are per page, the total number of users, and the total number of pages. Use the `page` and `pageSize` query parameters to navigate through results.\n\n**What you get back:**\nEach user includes their Atlas user ID, full name, email address, and the date their account was created. Only active (non-deactivated) users are returned.","tags":["Users"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":100},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"},{"schema":{"type":"string","format":"email","description":"Filter by exact email address","example":"user@agency.com"},"required":false,"name":"email","in":"query"}],"responses":{"200":{"description":"Paginated list of active users","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":"string","format":"email"},"createdAt":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time","description":"Use createdAt instead","deprecated":true}},"required":["id","name","email","createdAt","created_at"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":100},"per_page":{"type":"integer","description":"Use pageSize instead","deprecated":true},"total":{"type":"integer","description":"Total matching items","example":10},"totalPages":{"type":"integer","description":"Total number of pages","example":1},"total_pages":{"type":"integer","description":"Use totalPages instead","deprecated":true}},"required":["page","pageSize","per_page","total","totalPages","total_pages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/opportunities":{"get":{"summary":"List opportunities","description":"Returns a paginated list of opportunities with optional filtering.\n\n**Date filters:**\n- `createdAfter` / `createdBefore` - only return opportunities created within a specific date range (ISO 8601)\n- `updatedAfter` / `updatedBefore` - only return opportunities last modified within a specific date range (ISO 8601). Filters on the `updatedAt` field\n\n**Incremental sync:**\nTo keep an external copy in sync, use `updatedAfter` as a cursor: on each run, request `updatedAfter=<the highest updatedAt you have seen so far>`, page through the results, and persist the maximum `updatedAt` across the rows you receive. Pass that stored value as `updatedAfter` on the next run to fetch only records that changed since. Because the bound is inclusive you may re-receive the boundary row — upsert by `id` to stay idempotent.\n\n**Deletions (tombstones):**\nSoft-deleted opportunities are excluded by default. Pass `includeDeleted=true` to also receive deleted opportunities as tombstones — each carries a populated `deletedAt` (live rows have `deletedAt: null`). Combine `includeDeleted=true` with `updatedAfter` to incrementally pick up deletions: a soft-delete bumps `updatedAt`, so the deleted row resurfaces in the next poll with `deletedAt` set.","tags":["Opportunities"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (min: 1)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Items per page (min: 1, max: 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"boolean","description":"Show archived opportunities only. Applies to live rows; when `includeDeleted=true`, soft-deleted opportunities are always returned as tombstones regardless of their archived state.","example":false},"required":false,"name":"archived","in":"query"},{"schema":{"type":"string","description":"Case-insensitive partial match on opportunity name"},"required":false,"name":"name","in":"query"},{"schema":{"type":"string","description":"Comma-separated pipeline stage ID(s)"},"required":false,"name":"stageId","in":"query"},{"schema":{"type":"string","description":"Comma-separated owner email(s)"},"required":false,"name":"ownerEmail","in":"query"},{"schema":{"type":"string","enum":["regular","speculative"],"description":"Filter by opportunity type","example":"regular"},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","enum":["open","won","lost"],"description":"Filter by opportunity status","example":"open"},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter by associated candidate (person) ID"},"required":false,"name":"candidateId","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only opportunities created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only opportunities created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only opportunities updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only opportunities updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted opportunities as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"List of opportunities","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"type":{"type":"string","enum":["regular","speculative"]},"status":{"type":"string","enum":["open","won","lost"]},"value":{"type":["integer","null"]},"archived":{"type":"boolean"},"conversionLikelihood":{"type":["integer","null"]},"conversionReasoning":{"type":["string","null"]},"conversionUpdatedAt":{"type":["string","null"],"format":"date-time"},"stage":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"position":{"type":"integer"},"type":{"type":"string","enum":["default_stage","converted"]}},"required":["id","name","position","type"]},"stageUpdatedAt":{"type":["string","null"],"format":"date-time"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":"string","format":"email"}},"required":["id","name","email"]},"speculativePersonId":{"type":["string","null"],"format":"uuid"},"rejectionReason":{"type":["string","null"]},"rejectedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 soft-delete timestamp. `null` for live opportunities; populated for tombstones (only returned when `includeDeleted=true`)"}},"required":["id","name","type","status","value","archived","conversionLikelihood","conversionReasoning","conversionUpdatedAt","stage","stageUpdatedAt","owner","rejectionReason","rejectedAt","createdAt","updatedAt","deletedAt"]}},"total":{"type":"integer","description":"Total number of opportunities matching the filter, across all pages"},"page":{"type":"integer","description":"1-based page index that produced this response"},"pageSize":{"type":"integer","description":"Maximum number of items per page (echoed back from the request)"},"hasMore":{"type":"boolean"}},"required":["status","data","total","page","pageSize","hasMore"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}},"post":{"summary":"Create an opportunity","description":"Creates a new opportunity with optional relations (prospects, companies, job leads). When candidateId is provided the opportunity type is set to speculative; otherwise it is regular. The first pipeline stage is assigned automatically. When no name is provided and the opportunity has more than one prospect or any target company / job lead, a title is generated by the AI title service; otherwise a deterministic default name is used. AI title generation failures are logged and the deterministic name is kept — the create still succeeds.\n\n**Target companies.** The opportunity's target companies are the union of the explicit `companyIds` you pass and the current company resolved for each prospect (resolved in order: an explicit per-person company, then the prospect's `headlineCompanyId`, then their existing company-contact link). Companies are deduplicated by ID, so a prospect whose company is already in `companyIds` does not produce a second entry. Because prospect companies are resolved by ID, linking your contacts to a real company up front (via `headline.companyId` on POST /api/v1/people) is what keeps duplicate stub companies off the opportunity. Every resolved company must belong to your agency or the request fails.","tags":["Opportunities"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOpportunityPayload"}}}},"responses":{"201":{"description":"Opportunity created","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"type":{"type":"string","enum":["regular","speculative"]},"notes":{"type":["string","null"]},"aiSummary":{"type":["string","null"]},"value":{"type":["integer","null"]},"stageId":{"type":"string","format":"uuid"},"ownerId":{"type":"string","format":"uuid"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":"string","format":"email"}},"required":["id","name","email"]},"candidateId":{"type":["string","null"],"format":"uuid"},"createdById":{"type":"string","format":"uuid"},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":"string","format":"email"}},"required":["id","name","email"]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"prospectIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Resolved prospect person IDs that were attached (merged IDs rewritten to targets)"},"companyIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Target companies attached"},"jobLeadIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Projects linked as job leads"}},"required":["id","name","type","notes","aiSummary","value","stageId","ownerId","owner","candidateId","createdById","createdBy","createdAt","updatedAt","prospectIds","companyIds","jobLeadIds"]}},"required":["status","data"]}}}},"400":{"description":"A referenced person is currently being merged. Retry after the merge completes.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Some people are currently being merged..."}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"409":{"description":"Agency has no opportunity pipeline configured...","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"No opportunity pipeline configured for this agency. Create at least one opportunity stage before creating opportunities."}}}},"415":{"description":"Unsupported Media Type. The request body must be sent with `Content-Type: application/json` (charset parameters are accepted). Other content types are rejected up front to avoid silently discarding the payload.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"No opportunity pipeline configured for this agency. Create at least one opportunity stage before creating opportunities."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/opportunities/stages":{"get":{"summary":"List opportunity stages","description":"Returns the pipeline stages configured for the authenticated agency, ordered by `position` ASC. Soft-deleted stages are excluded.","tags":["Opportunities"],"security":[{"BearerAuth":[]}],"responses":{"200":{"description":"List of opportunity stages","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"position":{"type":"integer"},"type":{"type":"string","enum":["default_stage","converted"]}},"required":["id","name","position","type"]}}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/opportunities/{id}":{"get":{"summary":"Get opportunity details","description":"Returns a single opportunity including its current stage, owner, creator, speculative candidate (for speculative opportunities), attached prospects, target companies, and linked job leads. The `status` field is computed from the stage type and rejection state.","tags":["Opportunities"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Opportunity ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Opportunity details","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"type":{"type":"string","enum":["regular","speculative"]},"status":{"type":"string","enum":["open","lost","won"],"description":"Computed from stage type + rejection state"},"value":{"type":["integer","null"]},"notes":{"type":["string","null"],"description":"Single free-text notes blob stored on the opportunity itself (set via the `notes` field on POST). Not related to `opportunityNotes`, which is the list of individual note records added by users in Atlas."},"aiSummary":{"type":["string","null"]},"archived":{"type":"boolean"},"conversionLikelihood":{"type":["integer","null"],"description":"AI-computed conversion likelihood as a percentage integer (0–100)"},"conversionReasoning":{"type":["string","null"]},"conversionUpdatedAt":{"type":["string","null"],"format":"date-time"},"stage":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"position":{"type":"integer"},"type":{"type":"string","enum":["converted","default_stage"]},"opportunitiesCount":{"type":"integer","description":"Number of non-archived, non-rejected opportunities in this stage"},"opportunitiesTotalSum":{"type":"integer","description":"Summed monetary value of non-archived, non-rejected opportunities in this stage"}},"required":["id","name","position","type","opportunitiesCount","opportunitiesTotalSum"]},"stageId":{"type":"string","format":"uuid"},"stageUpdatedAt":{"type":["string","null"],"format":"date-time"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":"string"}},"required":["id","name","email"]},"ownerId":{"type":"string","format":"uuid"},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":"string"}},"required":["id","name","email"]},"speculativePersonId":{"type":["string","null"],"format":"uuid"},"speculativePerson":{"type":["object","null"],"properties":{"personId":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"avatar":{"type":["string","null"]},"email":{"type":["string","null"]},"headlineRole":{"type":["string","null"]},"headlineCompanyName":{"type":["string","null"]},"headlineCompanyId":{"type":["string","null"],"format":"uuid"}},"required":["personId","firstName","lastName","avatar","email","headlineRole","headlineCompanyName","headlineCompanyId"],"description":"Populated when type is \"speculative\"; null otherwise"},"candidate":{"type":["object","null"],"properties":{"personId":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"avatar":{"type":["string","null"]},"email":{"type":["string","null"]},"headlineRole":{"type":["string","null"]},"headlineCompanyName":{"type":["string","null"]},"headlineCompanyId":{"type":["string","null"],"format":"uuid"}},"required":["personId","firstName","lastName","avatar","email","headlineRole","headlineCompanyName","headlineCompanyId"],"description":"Alias of speculativePerson retained for REST-consumer parity"},"rejectionReason":{"type":["string","null"],"enum":["cancelled","went_cold","uncompetitive_fees","candidate_no_longer_available","no_longer_relevant","position_filled","went_with_competitor","hired_internally","no_reason","moved_company","no_longer_interesting","reduced_spending"]},"rejectedAt":{"type":["string","null"],"format":"date-time"},"rejectedBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":"string"}},"required":["id","name","email"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"leadCount":{"type":"integer"},"activeCandidateCampaignsCount":{"type":"integer"},"jobLeads":{"type":"array","items":{"type":"object","properties":{"jobId":{"type":"string","format":"uuid"},"roleName":{"type":"string"},"companyName":{"type":["string","null"]},"companyId":{"type":["string","null"],"format":"uuid"},"companyLogo":{"type":["string","null"]}},"required":["jobId","roleName","companyName","companyId","companyLogo"]}},"companies":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"TargetCompany row ID"},"companyId":{"type":"string","format":"uuid"},"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"logo":{"type":["string","null"]},"employeeCount":{"type":["integer","null"]},"size":{"type":["string","null"]},"summary":{"type":["string","null"]},"industry":{"type":["array","null"],"items":{"type":"string"}},"location":{"type":["object","null"],"properties":{"raw":{"type":["string","null"],"description":"Unstructured location string (e.g. provider-normalized address text)"},"streetAddress":{"type":["string","null"],"description":"Street address line 1"},"addressLine2":{"type":["string","null"],"description":"Street address line 2"},"city":{"type":["string","null"],"description":"City / locality"},"region":{"type":["string","null"],"description":"Region / state"},"postalCode":{"type":["string","null"],"description":"Postal / ZIP code"},"country":{"type":["string","null"],"description":"Country"},"metro":{"type":["string","null"],"description":"Metro area (US only)"},"formattedAddress":{"type":["string","null"],"description":"Provider-normalized \"city, region, country\" string"},"latitude":{"type":["number","null"],"description":"Latitude in decimal degrees"},"longitude":{"type":["number","null"],"description":"Longitude in decimal degrees"}},"required":["raw","streetAddress","addressLine2","city","region","postalCode","country","metro","formattedAddress","latitude","longitude"],"description":"Null when the company has no usable location data; otherwise the shared REST location DTO shared with companies/projects/people endpoints"}},"required":["id","name","logo","employeeCount","size","summary","industry","location"]}},"required":["id","companyId","company"]}},"prospects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Opportunity-person row ID — identifies this prospect link only. Not a person ID, and NOT valid as a `companyContactIds` value on POST /api/v1/projects (use `companyContact.id` from GET /api/v1/people for that)."},"personId":{"type":"string","format":"uuid","description":"Atlas person ID — use this for person endpoints and as a `prospectIds` value."},"contactId":{"type":"string","format":"uuid","description":"Alias of `id` (the opportunity-person row) — retained for REST-consumer parity. Despite the name, this is not a CompanyContact junction ID."},"createdAt":{"type":["string","null"],"format":"date-time"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"avatar":{"type":["string","null"]},"email":{"type":["string","null"]},"headlineRole":{"type":["string","null"]},"headlineCompanyName":{"type":["string","null"]},"headlineCompanyId":{"type":["string","null"],"format":"uuid"}},"required":["id","personId","contactId","createdAt","firstName","lastName","avatar","email","headlineRole","headlineCompanyName","headlineCompanyId"]}},"opportunityNotes":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"text":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]}},"required":["id","text","createdAt","createdBy"]},"description":"Individual note records added to the opportunity by users in Atlas, each with its own author and timestamp. Not related to the top-level `notes` string, which is the free-text blob stored on the opportunity itself."},"users":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"OpportunityUser row ID"},"userId":{"type":"string","format":"uuid"},"user":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]}},"required":["id","userId","user"]},"description":"Opportunity members/collaborators"},"outreachCampaigns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"status":{"type":"string"},"type":{"type":"string"},"projectId":{"type":["string","null"],"format":"uuid"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]}},"required":["id","name","status","type","projectId","createdAt","updatedAt","owner"]}}},"required":["id","name","type","status","value","notes","aiSummary","archived","conversionLikelihood","conversionReasoning","conversionUpdatedAt","stage","stageId","stageUpdatedAt","owner","ownerId","createdBy","speculativePersonId","speculativePerson","candidate","rejectionReason","rejectedAt","rejectedBy","createdAt","updatedAt","leadCount","activeCandidateCampaignsCount","jobLeads","companies","prospects","opportunityNotes","users","outreachCampaigns"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Opportunity not found or belongs to a different agency","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"fail","error":"Opportunity not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"patch":{"summary":"Add linkages to an opportunity","description":"Additively links prospects (person IDs), target companies, and job-lead projects to an existing opportunity. All body fields are optional but at least one must be provided. Existing linkages are never removed, and re-adding an already-linked ID is a no-op (still returns 200). Merged person IDs are resolved to their canonical person automatically. Linking a job-lead project also adds its company as a target company, and attaching a prospect adds their current company as a target company — mirroring the create endpoint. The response echoes the full current linkage sets (not just the additions).","tags":["Opportunities"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Opportunity ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOpportunityPayload"}}}},"responses":{"200":{"description":"Linkages added","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"prospectIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"All prospect person IDs currently linked to the opportunity"},"companyIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"All target company IDs currently linked to the opportunity"},"jobLeadIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"All job-lead project IDs currently linked to the opportunity"}},"required":["id","prospectIds","companyIds","jobLeadIds"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Opportunity not found or belongs to a different agency","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"fail","error":"Opportunity not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/prospects":{"get":{"summary":"List prospects","description":"Returns a paginated, agency-scoped list of prospects — people attached to opportunities (`opportunity_people` rows) — as a flat list, not nested under a single opportunity.\n\n**Relationship to the Spec CV / float workflow.** A prospect is created whenever a person is attached to an opportunity. The Spec CV workflow (speculatively sending a candidate CV to a client with no active role) creates a *speculative* opportunity plus a prospect on float/send, so filtering with `opportunityType=speculative` surfaces spec-CV prospects, while `opportunityType=regular` returns prospects on standard business-development opportunities.\n\n**Filters:**\n- `opportunityType` — `regular` | `speculative` (the parent opportunity type)\n- `opportunityId` — restrict to a single opportunity\n- `createdAfter` / `createdBefore` — filter on when the prospect was added (ISO 8601 or YYYY-MM-DD)\n- `updatedAfter` / `updatedBefore` — filter on when the prospect was last modified (ISO 8601 or YYYY-MM-DD). Use `updatedAfter` as an incremental-sync cursor: persist the max `updatedAt` you receive and pass it back on the next poll to fetch only what changed (upsert by `id`).\n- `includeDeleted` — when `true`, soft-deleted prospects are included as tombstones (with a populated `deletedAt`). Pair with `updatedAfter` to incrementally sync deletions: a soft-delete bumps `updatedAt`, so the removed prospect resurfaces on the next poll with `deletedAt` set.\n\n`addedBy` is populated from the prospect creator where recorded; it is `null` for legacy rows that predate creator attribution.","tags":["Prospects"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["regular","speculative"],"description":"Filter by the type of the parent opportunity. `speculative` opportunities are created by the Spec CV / float workflow (sending a candidate CV to a client with no active role); `regular` opportunities are standard business-development opportunities.","example":"regular"},"required":false,"name":"opportunityType","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only return prospects belonging to this opportunity"},"required":false,"name":"opportunityId","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only prospects created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only prospects created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only prospects updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only prospects updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted prospects as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"List of prospects","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"OpportunityPerson row ID (the prospect link)"},"personId":{"type":"string","format":"uuid","description":"The linked person ID"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"headlineRole":{"type":["string","null"],"description":"The person's current headline role"},"headlineCompanyName":{"type":["string","null"],"description":"The person's current headline company name"},"opportunity":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"type":{"type":"string","enum":["regular","speculative"]}},"required":["id","name","type"]},"addedBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":"string"}},"required":["id","name","email"],"description":"The user who added the prospect. `null` for legacy rows created before attribution was recorded."},"createdAt":{"type":"string","format":"date-time","description":"When the prospect was added to the opportunity (ISO 8601)"},"updatedAt":{"type":"string","format":"date-time","description":"When the prospect was last modified (ISO 8601)"},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Soft-delete timestamp (ISO 8601). `null` for live prospects; populated for tombstones (only returned when `includeDeleted=true`)."}},"required":["id","personId","firstName","lastName","headlineRole","headlineCompanyName","opportunity","addedBy","createdAt","updatedAt","deletedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number (1-indexed)"},"pageSize":{"type":"integer","description":"Items per page"},"total":{"type":"integer","description":"Total prospects matching the filter, across all pages"},"totalPages":{"type":"integer","description":"Total number of pages"}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/campaigns":{"get":{"summary":"List campaigns","description":"Returns a paginated list of active outreach campaigns for a given project or opportunity.","tags":["Campaigns"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["project","opportunity"],"description":"Type of parent entity","example":"project"},"required":true,"name":"entityType","in":"query"},{"schema":{"type":"string","format":"uuid","description":"ID of the project or opportunity","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"entityId","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Items per page","example":25},"required":false,"name":"pageSize","in":"query"}],"responses":{"200":{"description":"Paginated list of campaigns","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Campaign ID"},"name":{"type":"string","description":"Campaign name","example":"Q1 Outreach"},"status":{"type":"string","enum":["active"],"description":"Campaign status","example":"active"},"type":{"type":"string","enum":["candidate_outreach","prospect_outreach","speculative"],"description":"Campaign type","example":"candidate_outreach"},"ownerId":{"type":"string","format":"uuid","description":"Owner user ID"},"createdAt":{"type":["string","null"],"description":"ISO 8601 creation date","example":"2025-01-01T00:00:00.000Z"}},"required":["id","name","status","type","ownerId","createdAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total number of matching campaigns","example":1},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}},"post":{"summary":"Create a campaign","description":"Creates a reusable **outreach campaign template** — a named sequence of 1–20 steps — under a project (`entityType: \"project\"` → candidate outreach) or an opportunity (`entityType: \"opportunity\"` → prospect outreach). The campaign **type is derived from the parent**; do not send it.\n\nStep `subject`/`body` may contain `{{variable}}` placeholders (see the **Campaigns** tag for the full variable reference). Standard placeholders are validated at create time — an unknown name is rejected with 422. `{{ai:...}}` variables are stored verbatim and returned per step under `aiVariables` for you to resolve before launch. `{{manual:...}}` is not supported and is rejected with 422.\n\nThis endpoint **does not resolve variables and does not send anything**. To launch the template at a person, use `POST /api/v1/campaigns/{id}/launch`.","tags":["Campaigns"],"security":[{"BearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":255,"description":"Campaign name","example":"Q1 Senior Engineers Outreach"},"entityType":{"type":"string","enum":["project","opportunity"],"description":"Parent entity type","example":"project"},"entityId":{"type":"string","format":"uuid","description":"Parent id — a project id when entityType is \"project\", otherwise an opportunity id.","example":"550e8400-e29b-41d4-a716-446655440000"},"ownerEmail":{"type":"string","maxLength":255,"format":"email","description":"Email of the campaign owner. Must match an active user in your agency, otherwise 404.","example":"recruiter@agency.com"},"steps":{"type":"array","items":{"type":"object","properties":{"position":{"type":"integer","minimum":1,"description":"Step order — 1, 2, 3… unique and contiguous within the campaign, no skipping numbers","example":1},"type":{"type":"string","enum":["email","li_inmail","li_request","phone_call","todo"],"description":"The channel: email, li_inmail (LinkedIn InMail), li_request (LinkedIn connection request), phone_call, or todo.","example":"email"},"dispatchType":{"type":"string","enum":["new_thread","reply_to"],"description":"Email steps only, and required for them. new_thread = start a brand new email; reply_to = reply on the same thread as the previous email step.","example":"new_thread"},"subject":{"type":"string","minLength":1,"maxLength":2000,"description":"Email subject. Required for new_thread email steps. May contain the same {{placeholder}} and {{ai:...}} tokens as body.","example":"Exciting {{job_role}} role at {{client_name}}"},"body":{"type":"string","maxLength":50000,"description":"The message text. Required for every step type except phone_call. May contain {{static}}, {{ai:prompt}}, and time-sensitive placeholders — see the Campaigns tag.\n\nPlain text by default: a blank line between sentences starts a new paragraph; a single line break becomes a line break within the same paragraph. There is no automatic bullet-point support — a leading dash or asterisk shows up literally.\n\nFor real bullet points, bold, or links, write actual HTML tags: paragraphs (<p>), line breaks (<br>), bold (<strong>), italics (<em>), underline (<u>), links (<a href=\"...\">), bullet/numbered lists (<ul>/<ol>/<li>), headings (<h1>–<h6>), and basic tables. Anything else is silently removed for safety. Once the body contains any HTML tag the whole thing switches to HTML mode and the blank-line-means-new-paragraph rule stops applying, so do not mix styles — if you use any HTML tag, wrap every paragraph in <p>...</p>.\n\nDo not send the rendered HTML returned by GET; that is the finished, variable-substituted output, not valid input. Literal angle-bracket text (e.g. Vector<Item>, <https://example.com>) is preserved and HTML-escaped when rendered."},"scheduledOn":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"An exact calendar date (YYYY-MM-DD) to send this step, only used on the first step. Mutually exclusive with intervalDays. The first step must have either scheduledOn or intervalDays — that is what tells the system when to start; a sequence whose first step has neither cannot be scheduled and is rejected at launch.","example":"2026-07-01"},"intervalTime":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"What time of day (HH:mm) to send this step","example":"09:00"},"intervalDays":{"type":"integer","minimum":0,"description":"Instead of a fixed date, wait this many days after the previous step. Mutually exclusive with scheduledOn.","example":3},"businessDays":{"type":"boolean","default":true,"description":"Only schedule on working days. A campaign uses one value for its whole sequence, not one per step. On POST /api/v1/campaigns the value of the FIRST step by position is applied to every step and the others are ignored; in the `steps` override of POST /api/v1/campaigns/{id}/launch the field is ignored entirely and the parent template's first step by position is used — the same value the campaign UI shows."},"businessHours":{"type":"boolean","default":true,"description":"Only schedule during working hours. A campaign uses one value for its whole sequence, not one per step. On POST /api/v1/campaigns the value of the FIRST step by position is applied to every step and the others are ignored; in the `steps` override of POST /api/v1/campaigns/{id}/launch the field is ignored entirely and the parent template's first step by position is used — the same value the campaign UI shows."},"timezone":{"type":"string","description":"IANA timezone, stored on the step only. A launched campaign derives one timezone for its whole sequence from the person's location, falling back to the campaign owner's, so this field is accepted but ignored in the `steps` override of POST /api/v1/campaigns/{id}/launch.","example":"Europe/London"},"fileIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":10,"default":[],"description":"Attachment file ids (email steps only), max 10"}},"required":["position","type","body"]},"minItems":1,"maxItems":20,"description":"Ordered campaign steps (1–20). position values must be unique; steps run in ascending position order."}},"required":["name","entityType","entityId","ownerEmail","steps"]}}}},"responses":{"201":{"description":"Created campaign with its steps","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Campaign ID"},"name":{"type":"string","description":"Campaign name","example":"Q1 Outreach"},"status":{"type":"string","enum":["active"],"description":"Campaign status","example":"active"},"type":{"type":"string","enum":["candidate_outreach","prospect_outreach","speculative"],"description":"Campaign type","example":"candidate_outreach"},"ownerId":{"type":"string","format":"uuid","description":"Owner user ID"},"createdAt":{"type":["string","null"],"description":"ISO 8601 creation date","example":"2025-01-01T00:00:00.000Z"},"steps":{"type":"array","items":{"type":"object","properties":{"position":{"type":"integer","description":"1-based step order, as sent on create","example":1},"type":{"type":"string","enum":["email","li_inmail","li_request","phone_call","todo"],"description":"Step type","example":"email"},"dispatchType":{"type":["string","null"],"enum":["new_thread","reply_to"],"description":"Email threading strategy (only relevant for type=email)","example":"new_thread"},"subject":{"type":["string","null"],"description":"Email subject line. Null for non-email steps or reply_to dispatch.","example":"Exciting opportunity at {{client_name}}"},"body":{"type":"string","description":"Step body/template (HTML for emails). May contain {{variable}} placeholders — see the Campaigns tag for the full variable reference."},"variables":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Variable type","example":"person_first_name"},"name":{"type":["string","null"],"description":"Optional placeholder name (e.g. an alias used in the step body)","example":"firstName"},"value":{"type":["string","null"],"description":"Pre-resolved value, if any. Null for template-level variables resolved at send time.","example":null}},"required":["type","name","value"]},"description":"Standard variables used in this step (person fields, job fields, time-sensitive, and `manual`). AI variables (`{{ai:...}}`) are surfaced separately in this step’s `aiVariables`."},"aiVariables":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The AI prompt — the {{ai:...}} instruction you resolve before launch.","example":"List the full and entire job description including formatting"},"value":{"type":["string","null"],"description":"The resolved value. Null in create/GET responses; supply it at launch.","example":null}},"required":["name","value"]},"description":"Distinct {{ai:...}} prompts in this step (body before subject), each with `value: null`. Resolve each `name` and supply the values at `POST /api/v1/campaigns/{id}/launch`."},"scheduledOn":{"type":["string","null"],"description":"Specific date (YYYY-MM-DD) to schedule this step. Mutually exclusive with intervalDays.","example":"2026-05-10"},"intervalTime":{"type":["string","null"],"description":"Desired time of day (HH:mm) for step execution","example":"09:00"},"intervalDays":{"type":["integer","null"],"description":"Days to wait after the previous step. Mutually exclusive with scheduledOn.","example":3},"businessDays":{"type":"boolean","description":"Whether to only count business days for scheduling"},"businessHours":{"type":"boolean","description":"Whether to only execute during business hours"},"timezone":{"type":["string","null"],"description":"IANA timezone for business hours calculation","example":"Europe/London"},"id":{"type":"string","format":"uuid","description":"Server-assigned step ID"},"fileIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Attachment file ids the step was created with (email steps only)","example":[]}},"required":["position","type","dispatchType","subject","body","variables","aiVariables","scheduledOn","intervalTime","intervalDays","businessDays","businessHours","timezone","id","fileIds"]},"description":"Ordered list of campaign steps"}},"required":["id","name","status","type","ownerId","createdAt","steps"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"403":{"description":"Forbidden — token lacks CAMPAIGNS read-write scope (e.g. a read-only / MCP token)","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Forbidden"}}}},"404":{"description":"The owner email, the parent project/opportunity, or a referenced file was not found in your agency","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"examples":{"ownerNotFound":{"value":{"status":"error","error":"Owner not found"}},"parentNotFound":{"value":{"status":"error","error":"Project not found"}},"fileNotFound":{"value":{"status":"error","error":"Some files not found"}}}}}},"422":{"description":"Validation failed. Zod errors (unknown {{placeholder}}, {{manual:...}}, empty {{ai:}}, duplicate step positions) use the field-error shape `{ errors: { formErrors, fieldErrors } }`; a closed/archived parent uses the message shape `{ error }`.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]}]},"examples":{"manualPlaceholder":{"summary":"A {{manual:...}} placeholder is present in a step","value":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"body":["{{manual:...}} placeholders are not supported"]}}}},"closedParent":{"summary":"The parent project is closed or the opportunity is archived","value":{"status":"error","error":"Project is closed"}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"501":{"description":"Endpoint is disabled in this environment (feature-flagged). Not a routing error — do not retry."}}}},"/api/v1/campaigns/{id}/launch":{"post":{"summary":"Launch a campaign for a person","description":"## Launch a campaign for a person\n\n`POST /api/v1/campaigns/{id}/launch`\n\n### What this actually does\n\nThink of a **campaign template** as a saved, reusable outreach sequence , like \"3-email cold outreach\" or \"InMail + follow-up.\" It's just a plan sitting in storage; nothing gets sent yet.\n\nCalling this endpoint **activates that plan for one specific person**. It fills in the blanks (their name, company, etc.), schedules each step, and starts sending. You do this once per person you want to reach with that template.\n\n### Before you start, you need three things\n\n1. **A campaign template ID** — find this by listing your templates with `GET /api/v1/campaigns`, or you already know it because you created one.\n2. **A person ID** — the person you want to send this campaign to, from `GET /api/v1/people` or wherever you got their record.\n3. **Your API token** — a secret string that proves who you are. It goes in every request as `Authorization: Bearer YOUR_SECRET_TOKEN`. Never share it or put it in code that's visible to others.\n\n> **If you're handing this to an AI to build for you:** tell it your API token (or how it should read it, e.g. from an environment variable), the base URL `https://api.recruitwithatlas.com`, and paste the examples below. Say explicitly which of the two modes you want — \"just launch it as-is\" or \"I want to write my own custom message\" — since they're different requests.\n\n### There are two ways to launch — pick one\n\n**Mode 1: Launch it as-is.** Use the template exactly as saved. You just say who it's for. (Most people want this.)\n\n**Mode 2: Write your own message for this launch.** Override the template's steps completely with your own custom content, just for this one send. This would be used if you have built your own app that customises the content of a campaign for each receipient. For instance, you are taking a candidate to market and you want to customise the reasons for hiring the candidate for each receipient.\n\nYou choose the mode by whether you include a `steps` field in your request. Leave it out for Mode 1, include it for Mode 2. Essentially, if you don't give me any steps, I will assume you want to use the template. If you give me steps, I assume you want to replace hte template.\n\n---\n\n### Mode 1: Launch the template as-is\n\nThe simplest possible request — just the person:\n\n```bash\ncurl https://api.recruitwithatlas.com/api/v1/campaigns/550e8400-e29b-41d4-a716-446655440000/launch \\\n  --request POST \\\n  --header 'Content-Type: application/json' \\\n  --header 'Authorization: Bearer YOUR_SECRET_TOKEN' \\\n  --data '{\n    \"personId\": \"550e8400-e29b-41d4-a716-446655440001\"\n  }'\n```\n\nThat's often all you need. The system automatically fills in things like the person's first name and current company from their record.\n\n**Two optional extras, only if you need them:**\n\n* `variables` — force a specific value instead of letting the system auto-fill it (e.g. you want to say \"Acme Corp\" even though their profile says something else).\n* `aiVariables` — some templates have a blank spot meant to be written by AI, like *\"summarise the job description in one sentence.\"* If your template has one of these, you must generate that text yourself (using your own AI/ChatGPT/whatever) and hand back the finished text here. Find out if a template needs this by calling `GET /api/v1/campaigns/{id}` first — it lists every AI blank the template contains, under `aiVariables`, with `value: null` until you fill it.\n\n```bash\ncurl https://api.recruitwithatlas.com/api/v1/campaigns/550e8400-e29b-41d4-a716-446655440000/launch \\\n  --request POST \\\n  --header 'Content-Type: application/json' \\\n  --header 'Authorization: Bearer YOUR_SECRET_TOKEN' \\\n  --data '{\n    \"personId\": \"550e8400-e29b-41d4-a716-446655440001\",\n    \"variables\": {\n      \"person_first_name\": \"Johnny\",\n      \"current_company\": \"Acme Corp\"\n    },\n    \"aiVariables\": [\n      { \"name\": \"summarise the job description\", \"value\": \"Senior Backend Engineer, 10y, London\" }\n    ]\n  }'\n```\n\n`aiVariables` isn't optional if the template needs it. If you skip a required one, you'll get an error telling you exactly which one is missing. If you are using an AI to create campaigns, I'd highly recommend using either full customisation or template.\n\n---\n\n### Mode 2: Write your own message just for this launch\n\nAdd a `steps` field with your own list of messages. This completely replaces what the template would have sent — Atlas just remembers it came from this template, for your own record-keeping.\n\n```bash\ncurl https://api.recruitwithatlas.com/api/v1/campaigns/550e8400-e29b-41d4-a716-446655440000/launch \\\n  --request POST \\\n  --header 'Content-Type: application/json' \\\n  --header 'Authorization: Bearer YOUR_SECRET_TOKEN' \\\n  --data '{\n    \"personId\": \"550e8400-e29b-41d4-a716-446655440001\",\n    \"steps\": [\n      {\n        \"position\": 1,\n        \"type\": \"email\",\n        \"dispatchType\": \"new_thread\",\n        \"subject\": \"Exciting opportunity at {{client_name}}\",\n        \"body\": \"Hi {{person_first_name}},\\n\\nAre you open to hearing about a new role?\",\n        \"scheduledOn\": \"2026-08-01\"\n      },\n      {\n        \"position\": 2,\n        \"type\": \"email\",\n        \"dispatchType\": \"reply_to\",\n        \"body\": \"Just following up on my last note!\",\n        \"intervalDays\": 3,\n        \"intervalTime\": \"09:00\"\n      }\n    ]\n  }'\n```\n\nEach item in `steps` is one message. Here's what every field means, in order:\n\n| Field | What it is | Example |\n| -- | -- | -- |\n| `position` | Order — 1, 2, 3... no skipping numbers | `1` |\n| `type` | The channel: `email`, <br>`li_inmail` (LinkedIn InMail),<br> `li_request` (LinkedIn connection request), <br>`phone_call`, <br>or `todo` | `\"email\"` |\n| `dispatchType` | Email only. <br>`new_thread` = brand new email, <br>`reply_to` = reply on the same thread as the previous email | `\"new_thread\"` |\n| `subject` | Email only, and only required if starting a new thread | `\"Exciting opportunity...\"` |\n| `body` | The message text. See formatting below | see below |\n| `scheduledOn` | An exact calendar date (`YYYY-MM-DD`) to send this step. Only usable on step 1 | `\"2026-08-01\"` |\n| `intervalDays` | Instead of a fixed date: wait this many days after the previous step | `3` |\n| `intervalTime` | What time of day to send it | `\"09:00\"` |\n\nYou need either `scheduledOn` or `intervalDays` on the first step (not both) — that's what tells the system when to start.\n\nThe template's scheduling protections — `businessDays` (only send on working days) and `businessHours` (only send during working hours) — always carry over from the template, as does the step `timezone` (resolved from the person's location, falling back to the campaign owner's). One value applies to the whole launched sequence: the template's first step by position, the same value the campaign UI shows. Sending any of the three in `steps` is accepted and ignored, not rejected — so a 201 does **not** mean your value was applied. Change them on the template instead.\n\nIf your custom message text has an AI blank like `{{ai:...}}` in it, you still need to fill that in via `aiVariables`, same as Mode 1.\n\n---\n\n### Writing the message text (`body`) — paragraphs and bullet points\n\nThe `body` field is plain text by default, but understands two simple rules:\n\n* A **blank line** between sentences → starts a new paragraph.\n* A **single line break** → a line break within the same paragraph (no blank line before it).\n\n```json\n\"body\": \"Hi {{person_first_name}},\\n\\nI wanted to reach out about an exciting opportunity at {{client_name}}.\\n\\nLooking forward to hearing from you!\"\n```\n\n(That `\\n\\n` is just \"type Enter twice\" — it's how you write a blank line inside a JSON text string.)\n\n**There's no automatic bullet-point support**. Typing a dash or asterisk at the start of a line just shows up as a literal dash or asterisk, it won't turn into a bullet.\n\nTo get real bullet points, bold text, or links, write actual HTML tags in `body` instead:\n\n```json\n\"body\": \"<p>Hi {{person_first_name}},</p><p>I wanted to reach out about an exciting opportunity at <strong>{{client_name}}</strong>. This role involves:</p><ul><li>Backend architecture</li><li>API design</li><li>Mentoring the team</li></ul><p>Let me know if you'd like to chat!</p>\"\n```\n\nSupported HTML: paragraphs (`<p>`), line breaks (`<br>`), bold (`<strong>`), italics (`<em>`), underline (`<u>`), links (`<a href=\"...\">`), bullet/numbered lists (`<ul>`/`<ol>`/`<li>`), headings (`<h1>`–`<h6>`), and basic tables. Anything else gets silently removed for safety.\n\n**One rule to remember:** once your `body` contains any HTML tag, the *whole* thing switches into \"HTML mode\" and the blank-line-means-new-paragraph trick above stops working for the rest of it. So don't mix the two styles — if you use any HTML tag, wrap every paragraph in `<p>...</p>` explicitly.\n\nOne thing not to do: don't copy the `body` text you see when you *fetch* a campaign (`GET /api/v1/campaigns/{id}`) and paste it back in when creating or launching one — that's the finished, rendered version, not something you should feed back in as input.\n\n---\n\n### What you get back when it works (success)\n\nYou'll get an HTTP status of `201` and a response like this:\n\n```json\n{\n  \"status\": \"ok\",\n  \"data\": {\n    \"id\": \"8c1f2e3a-...\",\n    \"outreachCampaignId\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"personId\": \"550e8400-e29b-41d4-a716-446655440001\",\n    \"type\": \"candidate_outreach\",\n    \"status\": \"active\",\n    \"createdAt\": \"2026-07-21T20:00:00.000Z\",\n    \"steps\": [ ... ]\n  }\n}\n```\n\n`status: \"active\"` means the first message is scheduled and will go out. `status: \"ready\"` means it's briefly paused — usually because we're still figuring out the person's timezone (e.g. their LinkedIn import hasn't finished yet) — it'll become active automatically shortly after.\n\n### If something goes wrong\n\nEvery error response looks like `{ \"status\": \"error\", \"error\": \"...\" }` with an explanation in plain English, plus one of these HTTP status codes:\n\n| Status code | Plain-English meaning |\n| -- | -- |\n| `401` | Your token is missing or invalid — check the `Authorization` header |\n| `403` | Your token doesn't have permission to launch campaigns |\n| `404` | The campaign, the person, or a file you referenced doesn't exist (or belongs to someone else) |\n| `409` | This person already has this campaign (or a conflicting one) running — you can't launch it twice |\n| `422` | Something in your request needs fixing — check the error message, it names the exact field (e.g. \"missing AI variable\" or \"step must have a body\") |\n| `429` | You're sending requests too fast — slow down and retry after a pause |\n| `501` | This feature is turned off for your account — this isn't a mistake on your end, don't retry, contact support instead |\n\nIf you're getting an AI to build this integration, tell it to always check the status code and surface the `error` message to you rather than silently failing.\n    ","tags":["Campaigns"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the campaign template to launch","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"ID of the person to launch the campaign for. The system automatically fills in things like their first name and current company from their record — often this field is all you need to send.","example":"550e8400-e29b-41d4-a716-446655440001"},"variables":{"type":"object","additionalProperties":{"type":"string","minLength":1},"description":"Optional overrides — force a specific value instead of letting the system auto-fill it (e.g. say \"Acme Corp\" even though the profile says something else). Keys must be overridable static variable types: person_first_name, person_last_name, client_name, job_role, advertised_job_role, job_link, target_company, current_company. Time-sensitive types (today, tomorrow, etc.) are resolved at send time and cannot be overridden. Values must be non-empty strings; unknown or time-sensitive keys are rejected with 422.","example":{"person_first_name":"Johnny","current_company":"Acme Corp"}},"aiVariables":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"The {{ai:...}} prompt text, exactly as returned per step by GET /api/v1/campaigns/{id}.","example":"summarise the job description"},"value":{"type":"string","minLength":1,"description":"Your resolved value for that prompt.","example":"Senior Backend Engineer, 10y, London"}},"required":["name","value"]},"description":"Resolved values for the template’s {{ai:...}} prompts — some templates have a blank spot meant to be written by AI (e.g. \"summarise the job description\"). Generate that text yourself and hand back the finished value here, one entry per distinct prompt (matched case/whitespace-insensitively). Find out which prompts a template needs by calling GET /api/v1/campaigns/{id} first (they are listed per step under aiVariables with value: null). Required when the template contains {{ai:...}} placeholders; a 422 lists any missing prompt names. The same prompt reused across steps takes one entry.","example":[{"name":"summarise the job description","value":"Senior Backend Engineer, 10y, London"}]},"steps":{"type":"array","items":{"type":"object","properties":{"position":{"type":"integer","minimum":1,"description":"Step order — 1, 2, 3… unique and contiguous within the campaign, no skipping numbers","example":1},"type":{"type":"string","enum":["email","li_inmail","li_request","phone_call","todo"],"description":"The channel: email, li_inmail (LinkedIn InMail), li_request (LinkedIn connection request), phone_call, or todo.","example":"email"},"dispatchType":{"type":"string","enum":["new_thread","reply_to"],"description":"Email steps only, and required for them. new_thread = start a brand new email; reply_to = reply on the same thread as the previous email step.","example":"new_thread"},"subject":{"type":"string","minLength":1,"maxLength":2000,"description":"Email subject. Required for new_thread email steps. May contain the same {{placeholder}} and {{ai:...}} tokens as body.","example":"Exciting {{job_role}} role at {{client_name}}"},"body":{"type":"string","maxLength":50000,"description":"The message text. Required for every step type except phone_call. May contain {{static}}, {{ai:prompt}}, and time-sensitive placeholders — see the Campaigns tag.\n\nPlain text by default: a blank line between sentences starts a new paragraph; a single line break becomes a line break within the same paragraph. There is no automatic bullet-point support — a leading dash or asterisk shows up literally.\n\nFor real bullet points, bold, or links, write actual HTML tags: paragraphs (<p>), line breaks (<br>), bold (<strong>), italics (<em>), underline (<u>), links (<a href=\"...\">), bullet/numbered lists (<ul>/<ol>/<li>), headings (<h1>–<h6>), and basic tables. Anything else is silently removed for safety. Once the body contains any HTML tag the whole thing switches to HTML mode and the blank-line-means-new-paragraph rule stops applying, so do not mix styles — if you use any HTML tag, wrap every paragraph in <p>...</p>.\n\nDo not send the rendered HTML returned by GET; that is the finished, variable-substituted output, not valid input. Literal angle-bracket text (e.g. Vector<Item>, <https://example.com>) is preserved and HTML-escaped when rendered."},"scheduledOn":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"An exact calendar date (YYYY-MM-DD) to send this step, only used on the first step. Mutually exclusive with intervalDays. The first step must have either scheduledOn or intervalDays — that is what tells the system when to start; a sequence whose first step has neither cannot be scheduled and is rejected at launch.","example":"2026-07-01"},"intervalTime":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"What time of day (HH:mm) to send this step","example":"09:00"},"intervalDays":{"type":"integer","minimum":0,"description":"Instead of a fixed date, wait this many days after the previous step. Mutually exclusive with scheduledOn.","example":3},"businessDays":{"type":"boolean","default":true,"description":"Only schedule on working days. A campaign uses one value for its whole sequence, not one per step. On POST /api/v1/campaigns the value of the FIRST step by position is applied to every step and the others are ignored; in the `steps` override of POST /api/v1/campaigns/{id}/launch the field is ignored entirely and the parent template's first step by position is used — the same value the campaign UI shows."},"businessHours":{"type":"boolean","default":true,"description":"Only schedule during working hours. A campaign uses one value for its whole sequence, not one per step. On POST /api/v1/campaigns the value of the FIRST step by position is applied to every step and the others are ignored; in the `steps` override of POST /api/v1/campaigns/{id}/launch the field is ignored entirely and the parent template's first step by position is used — the same value the campaign UI shows."},"timezone":{"type":"string","description":"IANA timezone, stored on the step only. A launched campaign derives one timezone for its whole sequence from the person's location, falling back to the campaign owner's, so this field is accepted but ignored in the `steps` override of POST /api/v1/campaigns/{id}/launch.","example":"Europe/London"},"fileIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":10,"default":[],"description":"Attachment file ids (email steps only), max 10"}},"required":["position","type","body"]},"minItems":1,"maxItems":20,"description":"Optional full step sequence (1–20). When provided, these steps REPLACE the template’s steps entirely — same shape and rules as POST /api/v1/campaigns, except businessDays, businessHours and timezone: those are campaign-level values the template owns, so anything you send for them is accepted and silently ignored (a 201 does not mean your value was applied). The launched campaign still records under the parent template via outreachCampaignId, but is NOT saved as a new reusable campaign and does not appear in GET /api/v1/campaigns. Omit to launch the template’s own steps. When steps is present, aiVariables must cover the {{ai:...}} prompts in these steps.","example":[{"position":1,"type":"email","dispatchType":"new_thread","subject":"Exciting {{job_role}} role at {{client_name}}","body":"Hi {{person_first_name}},\n\n{{ai:one-line personalised hook}}\n\nWe are hiring for your team.","scheduledOn":"2026-07-25","intervalTime":"09:00","fileIds":[]},{"position":2,"type":"email","dispatchType":"reply_to","body":"Hi {{person_first_name}}, floating this back to the top of your inbox.","intervalDays":3,"fileIds":[]}]}},"required":["personId"]}}}},"responses":{"201":{"description":"Campaign launched successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Created CandidateCampaign ID"},"outreachCampaignId":{"type":"string","format":"uuid","description":"Source template ID"},"personId":{"type":"string","format":"uuid","description":"Person ID"},"type":{"type":"string","enum":["candidate_outreach","prospect_outreach","speculative"]},"status":{"type":"string","enum":["active","ready"],"description":"`active` when the first step is scheduled immediately; `ready` when activation is deferred because the first step emails a person whose LinkedIn is still parsing (no timezone yet)."},"createdAt":{"type":["string","null"]},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Step ID"},"position":{"type":"integer","description":"Position in the step sequence","example":1},"type":{"type":"string","enum":["email","li_inmail","li_request","phone_call","todo"],"description":"Step type"},"dispatchType":{"type":["string","null"],"enum":["reply_to","new_thread"],"description":"Email threading strategy"},"status":{"type":["string","null"],"description":"Step status","example":"scheduled"},"subject":{"type":["string","null"],"description":"Resolved email subject"},"body":{"type":"string","description":"Resolved step body — template markup with variables substituted; rendered to HTML at send time, not here."},"emailAddress":{"type":["string","null"],"description":"Email address used for email steps"},"scheduledOn":{"type":["string","null"],"description":"YYYY-MM-DD date for scheduling","example":"2026-07-01"},"intervalDays":{"type":["integer","null"],"description":"Days to wait after the previous step"},"intervalTime":{"type":["string","null"],"description":"Time of day (HH:mm)","example":"09:00"},"businessDays":{"type":"boolean","description":"Only count business days"},"businessHours":{"type":"boolean","description":"Only execute during business hours"},"timezone":{"type":["string","null"],"description":"IANA timezone","example":"Europe/London"},"variables":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Variable type, or \"ai\" for an AI variable","example":"person_first_name"},"name":{"type":["string","null"],"description":"Alias used in the step body, if any.","example":null},"value":{"type":["string","null"],"description":"Resolved value. Null for send-time variables (resolved when the step is sent).","example":"Johnny"}},"required":["type","name","value"]},"description":"Resolved static / time-sensitive variables for this step (excludes AI — see aiVariables)."},"aiVariables":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The AI prompt — the {{ai:...}} instruction you resolve before launch.","example":"List the full and entire job description including formatting"},"value":{"type":["string","null"],"description":"The resolved value. Null in create/GET responses; supply it at launch.","example":null}},"required":["name","value"]},"description":"Resolved {{ai:...}} prompts applied to this step — the value you supplied at launch."}},"required":["id","position","type","dispatchType","status","subject","body","emailAddress","scheduledOn","intervalDays","intervalTime","businessDays","businessHours","timezone","variables","aiVariables"]}}},"required":["id","outreachCampaignId","personId","type","status","createdAt","steps"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"403":{"description":"Forbidden — token lacks CAMPAIGNS read-write scope (e.g. a read-only / MCP token)","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Forbidden"}}}},"404":{"description":"The campaign was not found / is not active / belongs to another agency, the person was not found, a referenced file was not found, or (opportunity campaigns) prospect seating failed because the person has no linked company","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"examples":{"campaignNotFound":{"value":{"status":"error","error":"Campaign not found"}},"personNotFound":{"value":{"status":"error","error":"Person not found"}},"fileNotFound":{"value":{"status":"error","error":"Some files not found"}},"noCurrentCompany":{"summary":"Opportunity launch: the person has no linked company (headline.company alone is display-only)","value":{"status":"error","error":"We couldn't identify a current company for <personId>. The person's headline company is display-only text — link a company via a work-experience entry or a company contact, or add them to the opportunity manually."}}}}}},"409":{"description":"The campaign cannot be launched for this person, for one of two reasons. (1) The candidate has been rejected on the project: rejecting is what stops a candidate's campaigns, so starting a new one would immediately re-create the state rejection exists to prevent — un-reject the candidate to launch again. (2) A clashing campaign already exists for this person; the scope depends on the campaign type — candidate_outreach (project-linked): any active campaign for this person on the project blocks, and a completed campaign from this same template also blocks relaunch; prospect_outreach (opportunity-linked): only an active campaign from this same template blocks (other templates may run concurrently); speculative: an active campaign from this same template blocks, as does any other active campaign for this person on the same speculative opportunity (its templates share the speculative person) — i.e. at most one active campaign per person per speculative opportunity; campaigns launched for other people never block. Draft campaigns never block. Distinguish the two cases by the `error` message. Terminal in both cases — do not retry the same pair.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"examples":{"rejectedCandidate":{"summary":"The candidate is rejected on the project","value":{"status":"error","error":"This candidate has been rejected on the project"}},"duplicateCampaign":{"summary":"A clashing campaign already exists","value":{"status":"error","error":"There is another campaign for the candidate"}}}}}},"422":{"description":"Validation failed. Zod errors (bad UUID, malformed aiVariables entry, time-sensitive override, empty `{{ai:}}` in an override step) use the field-error shape `{ errors: { formErrors, fieldErrors } }`; semantic failures (missing AI variables, an empty `{{ai:}}` placeholder in a template step, unresolvable static variable, no eligible email, invalid template step) use the message shape `{ error }`.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]}]},"examples":{"missingAiVariables":{"summary":"A required AI variable value was not supplied","value":{"status":"error","error":"Missing required AI variables: summarise the job description"}},"invalidTemplateStep":{"summary":"A template step fails launch validation (e.g. blank body, deleted file)","value":{"status":"error","error":"Step must have a body"}},"unresolvedStatic":{"summary":"A required static variable could not be resolved and was not overridden","value":{"status":"error","error":"Unresolved variables: current_company (step 2)"}},"noEligibleEmail":{"summary":"The person has no usable email for an email step","value":{"status":"error","error":"No eligible email address found for person"}},"zodValidation":{"summary":"The request body failed schema validation","value":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"personId":["Invalid uuid"]}}}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"501":{"description":"Endpoint is disabled in this environment (feature-flagged). Not a routing error — do not retry."}}}},"/api/v1/campaigns/{id}":{"get":{"summary":"Get a campaign by ID","description":"Returns a single outreach campaign template by ID — its full step sequence, scheduling settings, attached files, and, per step, the standard `variables` and the distinct `{{ai:...}}` prompts as `aiVariables`. Resolve each AI prompt using `GET /api/v1/people/{personId}` (experiences, headlineRole) and `GET /api/v1/projects/{entityId}` or `GET /api/v1/opportunities/{entityId}` (jobDescription), then supply the values at `POST /api/v1/campaigns/{id}/launch`.","tags":["Campaigns"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the campaign to retrieve","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Campaign details with steps, files, and variables summary","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Campaign ID"},"name":{"type":"string","description":"Campaign name","example":"Q1 Senior Engineers Outreach"},"status":{"type":"string","enum":["active","inactive"],"description":"Campaign status","example":"active"},"type":{"type":"string","enum":["candidate_outreach","prospect_outreach","speculative"],"description":"Campaign type","example":"candidate_outreach"},"ownerId":{"type":"string","format":"uuid","description":"Owner user ID"},"projectId":{"type":["string","null"],"format":"uuid","description":"Linked project ID (set for candidate_outreach)"},"opportunityId":{"type":["string","null"],"format":"uuid","description":"Linked opportunity ID (set for prospect_outreach / speculative)"},"createdAt":{"type":["string","null"],"description":"ISO 8601 creation timestamp","example":"2026-04-15T10:30:00.000Z"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 last update timestamp","example":"2026-05-01T14:22:00.000Z"},"steps":{"type":"array","items":{"type":"object","properties":{"position":{"type":"integer","description":"1-based step order, as sent on create","example":1},"type":{"type":"string","enum":["email","li_inmail","li_request","phone_call","todo"],"description":"Step type","example":"email"},"dispatchType":{"type":["string","null"],"enum":["new_thread","reply_to"],"description":"Email threading strategy (only relevant for type=email)","example":"new_thread"},"subject":{"type":["string","null"],"description":"Email subject line. Null for non-email steps or reply_to dispatch.","example":"Exciting opportunity at {{client_name}}"},"body":{"type":"string","description":"Step body/template (HTML for emails). May contain {{variable}} placeholders — see the Campaigns tag for the full variable reference."},"variables":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Variable type","example":"person_first_name"},"name":{"type":["string","null"],"description":"Optional placeholder name (e.g. an alias used in the step body)","example":"firstName"},"value":{"type":["string","null"],"description":"Pre-resolved value, if any. Null for template-level variables resolved at send time.","example":null}},"required":["type","name","value"]},"description":"Standard variables used in this step (person fields, job fields, time-sensitive, and `manual`). AI variables (`{{ai:...}}`) are surfaced separately in this step’s `aiVariables`."},"aiVariables":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The AI prompt — the {{ai:...}} instruction you resolve before launch.","example":"List the full and entire job description including formatting"},"value":{"type":["string","null"],"description":"The resolved value. Null in create/GET responses; supply it at launch.","example":null}},"required":["name","value"]},"description":"Distinct {{ai:...}} prompts in this step (body before subject), each with `value: null`. Resolve each `name` and supply the values at `POST /api/v1/campaigns/{id}/launch`."},"scheduledOn":{"type":["string","null"],"description":"Specific date (YYYY-MM-DD) to schedule this step. Mutually exclusive with intervalDays.","example":"2026-05-10"},"intervalTime":{"type":["string","null"],"description":"Desired time of day (HH:mm) for step execution","example":"09:00"},"intervalDays":{"type":["integer","null"],"description":"Days to wait after the previous step. Mutually exclusive with scheduledOn.","example":3},"businessDays":{"type":"boolean","description":"Whether to only count business days for scheduling"},"businessHours":{"type":"boolean","description":"Whether to only execute during business hours"},"timezone":{"type":["string","null"],"description":"IANA timezone for business hours calculation","example":"Europe/London"},"files":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"File ID","example":"ff000000-0000-0000-0000-000000000001"},"name":{"type":"string","description":"File name","example":"resume.pdf"}},"required":["id","name"]},"description":"Attachment files (only for type=email). Each entry has the file id and name."}},"required":["position","type","dispatchType","subject","body","variables","aiVariables","scheduledOn","intervalTime","intervalDays","businessDays","businessHours","timezone","files"]},"description":"Ordered list of campaign steps"}},"required":["id","name","status","type","ownerId","projectId","opportunityId","createdAt","updatedAt","steps"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Campaign not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/interviews":{"get":{"summary":"List interviews","description":"Returns a paginated list of interviews (and general meetings) filtered by person, project, status bucket, type, and start date range. Each item includes the linked candidate (for interview-type rows), organizer, participants, and quick existence flags for transcript, manual notes, and AI notes.\n\n**Incremental sync:** `startDate` / `endDate` bound the *scheduled* time and are not a sync cursor. Use `createdAfter` (with `createdBefore`) for the initial historical backfill, then poll with `updatedAfter`: persist the highest `updatedAt` you receive and pass it back on the next poll. `updatedAfter` matches a change to either the interview or its underlying meeting, and the reported `updatedAt` is the later of the two. Re-send the cursor with a small overlap (e.g. a minute earlier than the highest `updatedAt` seen) and de-duplicate by `id`, so a row written mid-page is never skipped. Add `includeDeleted=true` to receive soft-deleted interviews as tombstones (`deletedAt` populated) — combined with `updatedAfter` this reliably surfaces deletions, because a soft-delete bumps `updatedAt`.","tags":["Interviews"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Filter by person UUID — matches when the person is the candidate or appears in the interview participants","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter by linked project UUID","example":"660e8400-e29b-41d4-a716-446655440001"},"required":false,"name":"projectId","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Return interviews starting on or after this ISO 8601 timestamp (inclusive)","example":"2026-01-01T00:00:00Z"},"required":false,"name":"startDate","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Return interviews starting on or before this ISO 8601 timestamp (inclusive)","example":"2026-02-01T00:00:00Z"},"required":false,"name":"endDate","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only interviews created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only interviews created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only interviews updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only interviews updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted interviews as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"string","enum":["scheduled","finished"],"description":"`scheduled` matches upcoming / in-progress statuses (draft, created, scheduled, coming, waiting_to_join, live, scheduling). `finished` matches terminal statuses (completed, transcribed, failed, canceled, no_answer).","example":"scheduled"},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","enum":["general","interview"],"description":"Filter by interview.type — `general` for general meetings, `interview` for candidate interviews","example":"interview"},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","enum":["asc","desc"],"default":"desc","description":"Sort order by `createdAt` (record creation time) — `desc` (default, newest first) or `asc` (oldest first). `id` is used as a stable tie-breaker.","example":"desc"},"required":false,"name":"order","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of interviews","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"description":{"type":["string","null"]},"location":{"type":["string","null"]},"meetingUrl":{"type":["string","null"]},"method":{"type":["string","null"],"enum":["video_call","phone_call","in_person"],"description":"Communication method (interview.communicationMethod)"},"status":{"type":"string","description":"Meeting status — see the status filter for the lifecycle buckets"},"startAt":{"type":["string","null"]},"endAt":{"type":["string","null"]},"createdAt":{"type":["string","null"]},"updatedAt":{"type":["string","null"],"description":"Effective last-updated timestamp: the later of the interview row and its underlying meeting. Use this value as the cursor for `updatedAfter` polling."},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live interviews; populated only for tombstones returned when `includeDeleted=true`."},"organizer":{"type":["object","null"],"properties":{"id":{"type":["string","null"],"format":"uuid","description":"Atlas user UUID when type is `user`. `null` when type is `external` (non-Atlas attendee)."},"name":{"type":"string"},"email":{"type":["string","null"]},"type":{"type":"string","enum":["user","external"]}},"required":["id","name","email","type"]},"participants":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Atlas user or person ID"},"name":{"type":"string"},"email":{"type":["string","null"]},"type":{"type":"string","enum":["user","person"]}},"required":["id","name","email","type"],"description":"Atlas user or person entry on the list shape — no `relationship` discriminator (organizer and candidate person are excluded from this array)."},"description":"Other people involved in the meeting, excluding the organizer and the candidate person."},"candidate":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Candidacy ID (candidate.id) — null for general meetings"},"person":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"headlineRole":{"type":["string","null"],"description":"Current/headline role for the candidate"},"headlineCompany":{"type":["string","null"],"description":"Current/headline company name for the candidate"}},"required":["id","firstName","lastName","headlineRole","headlineCompany"]}},"required":["id","person"],"description":"Linked candidate for interview-type meetings. `null` for general meetings."},"projectId":{"type":["string","null"],"format":"uuid"},"companyId":{"type":["string","null"],"format":"uuid","description":"Company resolved via Interview → Project → Company. Null when no project or no company is linked."},"hasTranscript":{"type":"boolean","description":"True when meeting.status is `transcribed`."},"hasManualNote":{"type":"boolean"},"hasAiNotes":{"type":"boolean"}},"required":["id","name","description","location","meetingUrl","method","status","startAt","endAt","createdAt","updatedAt","deletedAt","organizer","participants","candidate","projectId","companyId","hasTranscript","hasManualNote","hasAiNotes"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer"},"pageSize":{"type":"integer"},"total":{"type":"integer"},"hasMore":{"type":"boolean"}},"required":["page","pageSize","total","hasMore"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/interviews/{id}":{"get":{"summary":"Get an interview by ID","description":"Returns the lightweight interview envelope: candidate, project, company, organizer, participants and custom attributes. Heavy artifacts (transcript, manual note, AI notes) are served by dedicated sub-resource endpoints.","tags":["Interviews"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the interview to retrieve","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Interview details","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"description":{"type":["string","null"]},"location":{"type":["string","null"]},"meetingUrl":{"type":["string","null"]},"method":{"type":["string","null"],"enum":["video_call","phone_call","in_person"],"description":"Communication method (interview.communicationMethod)"},"status":{"type":"string","enum":["draft","created","scheduled","coming","waiting_to_join","live","completed","transcribed","failed","canceled","no_answer","scheduling"],"description":"Interview status (meeting.status)"},"direction":{"type":["string","null"],"enum":["inbound","outbound"]},"startAt":{"type":["string","null"],"description":"ISO 8601 start time"},"endAt":{"type":["string","null"],"description":"ISO 8601 end time"},"createdAt":{"type":["string","null"]},"candidate":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Candidacy ID (candidate.id)"},"person":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"name":{"type":"string"},"email":{"type":["string","null"]}},"required":["id","firstName","lastName","name","email"]}},"required":["id","person"]},"project":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"role":{"type":"string","description":"Job role (project.jobRole)"}},"required":["id","role"]},"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"],"description":"Company linked to the interview. Resolved from the linked project's company when present, otherwise from the interview's direct company link. Null when neither is set."},"organizer":{"type":["object","null"],"properties":{"id":{"type":["string","null"],"format":"uuid","description":"Atlas user ID, or null when the organizer is an external attendee not present in Atlas."},"name":{"type":"string"},"email":{"type":["string","null"]},"type":{"type":"string","enum":["user","person"]},"relationship":{"type":"string","enum":["user","candidate","company_contact","person"],"description":"How the participant relates to the interview: `user` (an Atlas user), `candidate` (the interview candidate), `company_contact` (a person who is an active contact of a company), or `person` (any other person)."}},"required":["id","name","email","type","relationship"],"description":"Interview organizer. Resolved to an Atlas user when the organizer email matches one of the interview users; otherwise returned as a raw attendee with `id` null."},"participants":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Atlas user or person ID"},"name":{"type":"string"},"email":{"type":["string","null"]},"type":{"type":"string","enum":["user","person"]},"relationship":{"type":"string","enum":["user","candidate","company_contact","person"],"description":"How the participant relates to the interview: `user` (an Atlas user), `candidate` (the interview candidate), `company_contact` (a person who is an active contact of a company), or `person` (any other person)."}},"required":["id","name","email","type","relationship"]},"description":"Flat list of users and people invited to the interview. Filter by type to separate them."},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]}}},"required":["id","name","description","location","meetingUrl","method","status","direction","startAt","endAt","createdAt","candidate","project","company","organizer","participants","customAttributes"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/interviews/{interviewId}/transcript":{"get":{"summary":"Get an interview transcript","description":"Returns the interview transcript as time-ordered speaker segments. Each segment resolves to an Atlas user (speakerUserId), an Atlas person (speakerPersonId), or neither (unknown speaker — the raw ASR label is preserved in `speaker`). Speaker identification is best-effort. A valid interview whose transcript has not been generated yet returns 200 with an empty `segments` array; 404 is reserved for an unknown or forbidden interview id.","tags":["Interviews"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the interview that owns the sub-resource","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"interviewId","in":"path"}],"responses":{"200":{"description":"Interview transcript","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"interviewId":{"type":"string","format":"uuid"},"language":{"type":["string","null"],"description":"BCP-47 language tag. Not currently stored — always null until added upstream."},"durationSeconds":{"type":["integer","null"],"description":"Length of the conversation in seconds, derived from the transcript span (last phrase end − first phrase start). Falls back to the meeting start/end window when no transcript timing is available."},"generatedAt":{"type":["string","null"],"description":"Best-effort transcript generation time. Currently the meeting completedAt timestamp (proxy)."},"segments":{"type":"array","items":{"type":"object","properties":{"speaker":{"type":"string","description":"Resolved speaker name, or the raw ASR label for unknown speakers"},"speakerUserId":{"type":["string","null"],"format":"uuid","description":"Atlas user ID when the speaker resolves to a user, otherwise null"},"speakerPersonId":{"type":["string","null"],"format":"uuid","description":"Atlas person ID when the speaker resolves to a person, otherwise null"},"startMs":{"type":"integer","description":"Segment start offset in milliseconds"},"endMs":{"type":"integer","description":"Segment end offset in milliseconds"},"text":{"type":"string","description":"Concatenated phrase text for this speaker turn"}},"required":["speaker","speakerUserId","speakerPersonId","startMs","endMs","text"]}}},"required":["interviewId","language","durationSeconds","generatedAt","segments"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/interviews/{interviewId}/note/manual":{"get":{"summary":"Get an interview manual note","description":"Returns the single manual note authored against an interview. A valid interview with no manual note yet returns 200 with an empty `body`; 404 is reserved for an unknown or forbidden interview id.","tags":["Interviews"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the interview that owns the sub-resource","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"interviewId","in":"path"}],"responses":{"200":{"description":"Interview manual note","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"interviewId":{"type":"string","format":"uuid"},"body":{"type":"string","description":"Manual note body as plain text (Interview.peopleNotes — authored via a plain-text field in the UI)."},"updatedAt":{"type":["string","null"],"description":"Last update time of the interview row that holds the note (not a per-note timestamp). Null when no note has been written yet (empty `body`)."}},"required":["interviewId","body","updatedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/interviews/{interviewId}/note/ai":{"get":{"summary":"Get interview AI note","description":"Returns the latest AI-generated note for the interview, broken into subject/response sections. `sourceTranscriptGeneratedAt` is intended to let clients detect stale notes when the transcript has been regenerated. A valid interview whose AI notes have not been generated yet returns 200 with an empty `sections` array; 404 is reserved for an unknown or forbidden interview id.","tags":["Interviews"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the interview that owns the sub-resource","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"interviewId","in":"path"}],"responses":{"200":{"description":"Interview AI note","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"interviewId":{"type":"string","format":"uuid"},"sections":{"type":"array","items":{"type":"object","properties":{"subject":{"type":"string","description":"Section heading"},"response":{"type":"string","description":"Section body (joined from the stored content lines)"}},"required":["subject","response"]}},"generatedAt":{"type":["string","null"],"description":"When the notes row was last generated (updatedAt/createdAt proxy)."},"modelVersion":{"type":["string","null"],"description":"AI model version. Not currently stored — always null."},"sourceTranscriptGeneratedAt":{"type":["string","null"],"description":"Generation time of the transcript the notes were derived from. Not currently stored — always null."}},"required":["interviewId","sections","generatedAt","modelVersion","sourceTranscriptGeneratedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/meetings":{"get":{"summary":"List meetings","description":"Returns a paginated list of meetings within your agency (this includes phone calls — filter on the `method` field). Interview-type meetings are NOT returned here — retrieve those from `/api/v1/interviews`. Each row carries the linked interview context (project, company, participants, organizer), a pre-computed `counterparty` classification, and boolean flags indicating whether transcript, manual notes, or AI notes exist. Heavy artifacts themselves are not returned here.\n\n**Breaking change (coordinate with consumers before release):** the `type` field (and the `type` query filter) have been removed, and interview-type meetings are now excluded from this list — use `/api/v1/interviews` for those and the new `counterparty` field/filter to distinguish who a meeting is with.\n\n**Incremental sync:** Use `createdAfter` for the initial historical backfill, then poll with `updatedAfter` (persist the highest `updatedAt` you receive and pass it back each poll). `updatedAfter` matches a change to either the meeting or its linked interview. Add `includeDeleted=true` to receive soft-deleted meetings as tombstones (`deletedAt` populated) — combined with `updatedAfter` this reliably surfaces deletions, because a soft-delete bumps `updatedAt`.","tags":["Meetings"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Filter by person (returns meetings whose interview links to a candidate of this person, or whose interview people include this person)","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter by project (returns meetings whose interview is linked to this project)","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"projectId","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Meetings starting on or after this UTC datetime (inclusive). Filters on meeting.startAt.","example":"2026-06-01T00:00:00Z"},"required":false,"name":"startDate","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Meetings starting on or before this UTC datetime (inclusive). Filters on meeting.startAt.","example":"2026-06-30T23:59:59Z"},"required":false,"name":"endDate","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Meetings created on or after this UTC datetime (inclusive). Use for the initial backfill of historical meetings.","example":"2025-01-01T00:00:00Z"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Meetings created on or before this UTC datetime (inclusive).","example":"2026-01-01T00:00:00Z"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Returns meetings whose meeting row OR linked interview was updated on or after this UTC datetime (inclusive). This is the primary cursor for incremental polling — persist the highest `updatedAt` you receive and pass it back on the next poll. Combine with `includeDeleted=true` to also pick up deletions (a soft-delete bumps `updatedAt`).","example":"2026-06-01T00:00:00Z"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Returns meetings whose meeting row OR linked interview was updated on or before this UTC datetime (inclusive).","example":"2026-06-30T23:59:59Z"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"When `true`, soft-deleted meetings are included as tombstones (the `deletedAt` field is populated). Defaults to `false`, which returns only live meetings.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"string","enum":["scheduled","finished"],"description":"`scheduled` matches upcoming/in-progress statuses (draft, created, scheduled, coming, waiting_to_join, live, scheduling). `finished` matches terminal statuses (completed, transcribed, failed, canceled, no_answer).","example":"scheduled"},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","enum":["contact","candidate","internal","unknown"],"description":"Filter by the pre-computed counterparty: `contact`, `candidate`, `internal` or `unknown`.","example":"candidate"},"required":false,"name":"counterparty","in":"query"},{"schema":{"type":"string","enum":["video_call","phone_call","in_person"],"description":"Filter by the interview communication method: `video_call`, `phone_call` or `in_person`. Use `phone_call` to retrieve only phone calls.","example":"phone_call"},"required":false,"name":"method","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated meetings","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"description":{"type":["string","null"]},"location":{"type":["string","null"]},"meetingUrl":{"type":["string","null"]},"method":{"type":["string","null"],"enum":["video_call","phone_call","in_person"],"description":"Interview communication method. Null when the meeting has no linked interview."},"direction":{"type":["string","null"],"enum":["inbound","outbound"],"description":"Call direction (`inbound`/`outbound`). Only populated for VoIP-sourced phone calls (e.g. RingOver, RingCentral); null for video meetings and manually logged calls."},"provider":{"type":["string","null"],"description":"Source/provider of the meeting (e.g. `atlas`, `ring_over`, `ring_central`, `mobile`, `calendar`). Null when unknown."},"status":{"type":"string","enum":["draft","created","scheduled","coming","waiting_to_join","live","completed","transcribed","failed","canceled","no_answer","scheduling"],"description":"Meeting status (meeting.status)"},"counterparty":{"type":"string","enum":["contact","candidate","internal","unknown"],"description":"Who the meeting is with, pre-computed from the participants. `contact` (client / hiring manager), `candidate`, `internal` (agency users only), or `unknown`. Priority when several apply: contact > candidate > internal > unknown."},"startAt":{"type":["string","null"],"description":"UTC ISO 8601 start time"},"endAt":{"type":["string","null"],"description":"UTC ISO 8601 end time"},"createdAt":{"type":["string","null"]},"updatedAt":{"type":["string","null"],"description":"Effective last-updated timestamp: the later of the meeting row and its linked interview. Use this value as the cursor for `updatedAfter` polling."},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live meetings; populated only for tombstones returned when `includeDeleted=true`."},"organizer":{"type":["object","null"],"properties":{"id":{"type":["string","null"],"description":"Atlas user ID when `type=user`, `null` when `type=external`."},"name":{"type":"string"},"email":{"type":["string","null"]},"type":{"type":"string","enum":["user","external"]}},"required":["id","name","email","type"]},"participants":{"type":"array","items":{"type":"object","properties":{"id":{"type":["string","null"],"description":"Atlas user ID when `type=user`, person ID when `type=person`, `null` when `type=external` (organizer attendee that does not exist in Atlas)."},"name":{"type":"string"},"email":{"type":["string","null"]},"type":{"type":"string","enum":["user","person","external"]}},"required":["id","name","email","type"]},"description":"Flat list of users and people invited to the meeting."},"projectId":{"type":["string","null"],"format":"uuid"},"companyId":{"type":["string","null"],"format":"uuid","description":"Company derived from the linked project (project.company) when present, otherwise from the interview."},"hasTranscript":{"type":"boolean"},"hasManualNote":{"type":"boolean"},"hasAiNotes":{"type":"boolean"}},"required":["id","name","description","location","meetingUrl","method","direction","provider","status","counterparty","startAt","endAt","createdAt","updatedAt","deletedAt","organizer","participants","projectId","companyId","hasTranscript","hasManualNote","hasAiNotes"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","example":1},"pageSize":{"type":"integer","example":25},"total":{"type":"integer","example":42},"totalPages":{"type":"integer","example":2}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/meetings/{meetingId}":{"get":{"summary":"Get a meeting","description":"Returns the core meeting record: schedule, status, organizer, participants, linked project/company, and custom attributes. The transcript, manual note, and AI notes are **not** included — each can be large, so they will be served by dedicated sub-resource endpoints. Use the list endpoint’s `hasTranscript` / `hasManualNote` / `hasAiNotes` flags to check whether those artefacts exist.\n\nThis endpoint serves **general meetings only**. If the requested ID resolves to a candidate interview, the response is a `422` whose body points at `GET /api/v1/interviews/{interviewId}`.","tags":["Meetings"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the meeting to retrieve","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"meetingId","in":"path"}],"responses":{"200":{"description":"Meeting detail","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"description":{"type":["string","null"]},"location":{"type":["string","null"]},"meetingUrl":{"type":["string","null"]},"method":{"type":["string","null"],"enum":["video_call","phone_call","in_person"],"description":"Communication method. Null when the meeting has no linked interview record."},"status":{"type":"string","enum":["draft","created","scheduled","coming","waiting_to_join","live","completed","transcribed","failed","canceled","no_answer","scheduling"],"description":"Meeting status (meeting.status)"},"direction":{"type":["string","null"],"enum":["inbound","outbound"],"description":"Call direction (`inbound`/`outbound`). Only populated for VoIP-sourced phone calls (e.g. RingOver, RingCentral); null for video meetings and manually logged calls."},"startAt":{"type":["string","null"],"description":"UTC ISO 8601 start time"},"endAt":{"type":["string","null"],"description":"UTC ISO 8601 end time"},"createdAt":{"type":["string","null"]},"updatedAt":{"type":["string","null"],"description":"Effective last-updated timestamp: the later of the meeting row and its linked interview record — the same value the list endpoint reports."},"organizer":{"type":["object","null"],"properties":{"id":{"type":["string","null"],"format":"uuid","description":"Atlas user ID when `type=user`; null when `type=external` (organizer not present in Atlas)."},"name":{"type":"string"},"email":{"type":["string","null"]},"type":{"type":"string","enum":["user","external"]},"relationship":{"type":"string","enum":["user","external"],"description":"Same as `type`: `user` for an Atlas user, `external` for an organizer not present in Atlas."}},"required":["id","name","email","type","relationship"],"description":"Meeting organizer — resolved the same way as the list shape: an Atlas user when the organizer email matches one of the meeting users, otherwise an `external` attendee with `id` null. Null when no organizer was recorded. The organizer may or may not also appear in `participants` — do not assume dedup."},"participants":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Atlas user or person ID"},"name":{"type":"string"},"email":{"type":["string","null"]},"type":{"type":"string","enum":["user","person"]},"relationship":{"type":"string","enum":["user","candidate","company_contact","person"],"description":"How the participant relates to the interview: `user` (an Atlas user), `candidate` (the interview candidate), `company_contact` (a person who is an active contact of a company), or `person` (any other person)."}},"required":["id","name","email","type","relationship"]},"description":"Flat list of users and people invited to the meeting. Filter by `type` to separate them."},"project":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"role":{"type":"string","description":"Job role (project.jobRole)"},"companyName":{"type":["string","null"],"description":"Name of the project's company, null when none is set"}},"required":["id","role","companyName"],"description":"Project linked to the meeting. Null when no project is associated."},"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"],"description":"Company linked to the meeting. Resolved from the linked project's company when present, otherwise from the direct company link. Null when neither is set."},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]}}},"required":["id","name","description","location","meetingUrl","method","status","direction","startAt","endAt","createdAt","updatedAt","organizer","participants","project","company","customAttributes"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Meeting not found or belongs to a different agency","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string"}},"required":["status","error"]}}}},"422":{"description":"meetingId is not a valid UUID, or the ID belongs to an interview (the body then carries `correctEndpoint: \"GET /api/v1/interviews/:interviewId\"`).","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","example":"This record is an interview, not a meeting. Use GET /api/v1/interviews/{interviewId} instead."},"correctEndpoint":{"type":"string","enum":["GET /api/v1/interviews/:interviewId"]}},"required":["status","error","correctEndpoint"]},{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{}}},"required":["status","errors"]}]}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/meetings/{meetingId}/transcript":{"get":{"summary":"Get a meeting transcript","description":"Returns the meeting transcript as time-ordered speaker segments. Each segment resolves to an Atlas user (speakerUserId), an Atlas person (speakerPersonId), or neither (unknown speaker — the raw ASR label is preserved in `speaker`). Speaker identification is best-effort. A valid meeting whose transcript has not been generated yet returns 200 with an empty `segments` array; 404 is reserved for an unknown or forbidden meeting id.\n\nThis endpoint serves **general meetings only**. If the requested ID resolves to a candidate interview, the response is a `422` whose body points at `GET /api/v1/interviews/{interviewId}/transcript`.","tags":["Meetings"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the meeting to retrieve","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"meetingId","in":"path"}],"responses":{"200":{"description":"Meeting transcript","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"meetingId":{"type":"string","format":"uuid"},"language":{"type":["string","null"],"description":"BCP-47 language tag. Not currently stored — always null until added upstream."},"durationSeconds":{"type":["integer","null"],"description":"Length of the conversation in seconds, derived from the transcript span (last phrase end − first phrase start). Falls back to the meeting start/end window when no transcript timing is available."},"generatedAt":{"type":["string","null"],"description":"Best-effort transcript generation time. Currently the meeting completedAt timestamp (proxy)."},"segments":{"type":"array","items":{"type":"object","properties":{"speaker":{"type":"string","description":"Resolved speaker name, or the raw ASR label for unknown speakers"},"speakerUserId":{"type":["string","null"],"format":"uuid","description":"Atlas user ID when the speaker resolves to a user, otherwise null"},"speakerPersonId":{"type":["string","null"],"format":"uuid","description":"Atlas person ID when the speaker resolves to a person, otherwise null"},"startMs":{"type":"integer","description":"Segment start offset in milliseconds"},"endMs":{"type":"integer","description":"Segment end offset in milliseconds"},"text":{"type":"string","description":"Concatenated phrase text for this speaker turn"}},"required":["speaker","speakerUserId","speakerPersonId","startMs","endMs","text"]}}},"required":["meetingId","language","durationSeconds","generatedAt","segments"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Meeting not found or belongs to a different agency","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string"}},"required":["status","error"]}}}},"422":{"description":"meetingId is not a valid UUID, or the ID belongs to an interview (the body then carries `correctEndpoint: \"GET /api/v1/interviews/:interviewId/transcript\"`).","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","example":"This record is an interview, not a meeting. Use GET /api/v1/interviews/{interviewId}/transcript instead."},"correctEndpoint":{"type":"string","enum":["GET /api/v1/interviews/:interviewId/transcript"]}},"required":["status","error","correctEndpoint"]},{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{}}},"required":["status","errors"]}]}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/meetings/{meetingId}/note/manual":{"get":{"summary":"Get a meeting manual note","description":"Returns the single manual note authored against a meeting, without the fat detail payload. A valid meeting with no manual note yet returns 200 with an empty `body` and `updatedAt: null`; 404 is reserved for an unknown or forbidden meeting id.\n\nThis endpoint serves **general meetings only**. If the requested ID resolves to a candidate interview, the response is a `422` whose body points at `GET /api/v1/interviews/{interviewId}/note/manual`.","tags":["Meetings"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the meeting to retrieve","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"meetingId","in":"path"}],"responses":{"200":{"description":"Meeting manual note","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"meetingId":{"type":"string","format":"uuid"},"body":{"type":"string","description":"Manual note body as plain text (the linked interview record’s peopleNotes — authored via a plain-text field in the UI). Empty string when no note has been written yet."},"updatedAt":{"type":["string","null"],"description":"Last update time of the interview record that holds the note (not a per-note timestamp). Null when no note has been written yet (empty `body`)."}},"required":["meetingId","body","updatedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Meeting not found or belongs to a different agency","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string"}},"required":["status","error"]}}}},"422":{"description":"meetingId is not a valid UUID, or the ID belongs to an interview (the body then carries `correctEndpoint: \"GET /api/v1/interviews/:interviewId/note/manual\"`).","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","example":"This record is an interview, not a meeting. Use GET /api/v1/interviews/{interviewId}/note/manual instead."},"correctEndpoint":{"type":"string","enum":["GET /api/v1/interviews/:interviewId/note/manual"]}},"required":["status","error","correctEndpoint"]},{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{}}},"required":["status","errors"]}]}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/meetings/{meetingId}/note/ai":{"get":{"summary":"Get a meeting AI note","description":"Returns the latest AI-generated note for a general meeting, broken into subject/response sections. A valid meeting whose AI notes have not been generated yet returns 200 with an empty `sections` array; 404 is reserved for an unknown or forbidden meeting id.\n\nThis endpoint serves **general meetings only**. If the requested ID resolves to a candidate interview, the response is a `422` whose body points at `GET /api/v1/interviews/{interviewId}/note/ai`.","tags":["Meetings"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the meeting to retrieve","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"meetingId","in":"path"}],"responses":{"200":{"description":"Meeting AI note","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"meetingId":{"type":"string","format":"uuid"},"sections":{"type":"array","items":{"type":"object","properties":{"subject":{"type":"string","description":"Section heading"},"response":{"type":"string","description":"Section body (joined from the stored content lines)"}},"required":["subject","response"]}},"generatedAt":{"type":["string","null"],"description":"When the notes row was last generated (updatedAt/createdAt proxy)."},"modelVersion":{"type":["string","null"],"description":"AI model version. Not currently stored — always null."},"sourceTranscriptGeneratedAt":{"type":["string","null"],"description":"Generation time of the transcript the notes were derived from. Not currently stored — always null."}},"required":["meetingId","sections","generatedAt","modelVersion","sourceTranscriptGeneratedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Meeting not found or belongs to a different agency","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string"}},"required":["status","error"]}}}},"422":{"description":"meetingId is not a valid UUID, or the ID belongs to an interview (the body then carries `correctEndpoint: \"GET /api/v1/interviews/:interviewId/note/ai\"`).","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","example":"This record is an interview, not a meeting. Use GET /api/v1/interviews/{interviewId}/note/ai instead."},"correctEndpoint":{"type":"string","enum":["GET /api/v1/interviews/:interviewId/note/ai"]}},"required":["status","error","correctEndpoint"]},{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{}}},"required":["status","errors"]}]}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/emails":{"get":{"summary":"List email activity","description":"Returns a keyset-paginated list of email activity within your agency. Each row represents a single email linked to a single person (an email matched to multiple people yields multiple rows), carrying metadata and a snippet — full message bodies are not returned. Private and hidden emails are always excluded.\n\n**Pagination (keyset):** Responses carry `pagination.hasMore` and `pagination.nextCursor` rather than a page number or total count (both are prohibitively expensive on large mailboxes). Request the first page without a cursor, then send the returned `nextCursor.cursorDate`/`cursorId` on each subsequent request. This stays O(pageSize) at any depth, so it is safe for large historical backfills — unlike page/offset pagination, which degrades sharply on deep pages.\n\n**Backfill (1–2 yr):** Loop the cursor until `hasMore` is `false`; optionally bound with `createdAfter` to cap the horizon.\n\n**Incremental sync:** Poll with `updatedAfter` and page the cursor within each poll. Overlap each poll slightly (e.g. `updatedAfter = lastPollStart - 60s`) and dedupe by `id` so a row written exactly at the boundary is never missed. Add `includeDeleted=true` to receive soft-deleted emails as tombstones (`deletedAt` populated).","tags":["Emails"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Filter to email activity linked to this person (one row is returned per person matched on an email, so this scopes to that person).","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter to emails attributed to this project. Only Atlas-composed emails (sent from within a project) carry a project link, so inbound mailbox-synced emails are excluded by this filter.","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"projectId","in":"query"},{"schema":{"type":"string","enum":["inbound","outbound"],"description":"Filter by email direction: `inbound` (received) or `outbound` (sent).","example":"inbound"},"required":false,"name":"direction","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only emails created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only emails created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only emails updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only emails updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted emails as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Items per page (max 100).","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Keyset pagination cursor: the `pagination.nextCursor.cursorDate` returned by the previous request. Send together with `cursorId` to fetch the next page in stable order that stays O(pageSize) at any depth. Omit for the first page.","example":"2026-01-05T12:00:00.000Z"},"required":false,"name":"cursorDate","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Keyset pagination cursor: the `pagination.nextCursor.cursorId` returned by the previous request. Must be sent together with `cursorDate`.","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"cursorId","in":"query"}],"responses":{"200":{"description":"Paginated email activity","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Stable identifier for this email-activity row (one per person matched on the email)."},"type":{"type":"string","enum":["email"]},"direction":{"type":["string","null"],"enum":["inbound","outbound"],"description":"`inbound` (received) or `outbound` (sent). Null when the source email has no resolved direction."},"timestamp":{"type":["string","null"],"description":"UTC ISO 8601 time the email was received (the activity timestamp)."},"subject":{"type":["string","null"]},"snippet":{"type":["string","null"],"description":"Short preview of the email body. Full message bodies are intentionally not exposed."},"threadId":{"type":["string","null"],"description":"Provider thread identifier, when available."},"personId":{"type":["string","null"],"format":"uuid","description":"The Atlas person this email activity is linked to."},"companyId":{"type":["string","null"],"format":"uuid","description":"Company derived from the person's active company contact at the time the activity was created."},"projectId":{"type":["string","null"],"format":"uuid","description":"Project this email was composed under, when applicable. Only Atlas-composed emails carry a project link; inbound mailbox-synced emails are null."},"user":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"email":{"type":["string","null"]}},"required":["id","name","email"],"description":"The recruiter whose mailbox this email belongs to (the mailbox owner)."},"createdAt":{"type":["string","null"]},"updatedAt":{"type":["string","null"],"description":"UTC ISO 8601 last-updated timestamp of the source email. Use this value as the cursor for `updatedAfter` polling."},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live emails; populated only for tombstones returned when `includeDeleted=true`."}},"required":["id","type","direction","timestamp","subject","snippet","threadId","personId","companyId","projectId","user","createdAt","updatedAt","deletedAt"]}},"pagination":{"type":"object","properties":{"pageSize":{"type":"integer","example":25},"hasMore":{"type":"boolean","description":"Whether more rows exist beyond this page. `true` means at least one further page is available — advance the `nextCursor` until it is `false`. No exact `total` is returned: counting the full email-activity set on every request is prohibitively expensive on large mailboxes.","example":false},"nextCursor":{"type":["object","null"],"properties":{"cursorDate":{"type":"string","description":"Pass back as `cursorDate` to fetch the next page."},"cursorId":{"type":"string","format":"uuid","description":"Pass back as `cursorId` to fetch the next page."}},"required":["cursorDate","cursorId"],"description":"Keyset cursor for the next page. Non-null when `hasMore` is `true` — send `cursorDate`/`cursorId` on the next request to page in stable order that stays O(pageSize) at any depth."}},"required":["pageSize","hasMore","nextCursor"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}}}}},"/api/v1/files":{"get":{"summary":"List files","description":"Use this endpoint to retrieve a paginated list of files that belong to your Atlas account.\n\nEach file includes its ID, type (e.g. `resume`), original filename, MIME type, size in bytes, the source it was created from, and the date it was uploaded. Use the returned file ID with other endpoints — for example, when creating an applicant on a project.\n\n**Filtering:**\nUse `type` and `source` to filter by file type and origin. Use `personId` to return only files linked to a single person, and `companyId` to return only files linked to a single company. All filters are combined with AND, and a valid ID with no matching files returns an empty list.\n\nFiles whose upload never completed are never returned — everything listed here can be downloaded.\n\n**Pagination:**\nResults are returned in pages. The response includes a `pagination` object with the current page, page size, total number of files, and total number of pages. Use the `page` and `pageSize` query parameters to navigate through results.\n\nThis endpoint does not return a download URL — use `GET /api/v1/files/{id}/download` to obtain a short-lived presigned URL for a specific file.","tags":["Files"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["resume","passport","rightToWork","insurance","brandedResume","incorporation","other"],"description":"Filter by file type","example":"resume"},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","enum":["branded_report","contract_document","email_attachment","manual_upload","manual_upload_outbound","manual_upload_campaign","manual_upload_resume","manual_upload_editor","manual_upload_company_file","manual_upload_person_file","manual_upload_contract_document","bulk_upload","application","applicant_form","manual_upload_applicant","api","idibu","broadbean"],"description":"Filter by file source","example":"api"},"required":false,"name":"source","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter to files linked to a single person","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter to files linked to a single company","example":"550e8400-e29b-41d4-a716-446655440000"},"required":false,"name":"companyId","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of files","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","description":"File type","example":"resume"},"filename":{"type":"string","description":"Original filename","example":"resume.pdf"},"mime_type":{"type":["string","null"],"description":"MIME type","example":"application/pdf"},"size":{"type":"integer","description":"File size in bytes","example":245832},"source":{"type":"string","description":"Where the file originated","example":"api"},"created_at":{"type":"string","format":"date-time","description":"Upload timestamp"}},"required":["id","type","filename","mime_type","size","source","created_at"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":100},"total":{"type":"integer","description":"Total matching items","example":10},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Upload a file","description":"Use this endpoint to upload a file to Atlas.\n\nThe file must be sent as multipart/form-data with two fields:\n- `file` - the binary file content (PDF, DOC, or DOCX, max 20 MB)\n- `type` - the file type: `resume`, `passport`, `rightToWork`, `insurance`, `brandedResume`, `incorporation`, or `other`. Only `resume` files are sent to the CV parser.\n\nAfter uploading, you will receive a file ID that can be used with other endpoints - for example, when attaching the file to a person, company, or project via `POST /api/v1/files/attach`.","tags":["Files"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"Binary file content"},"type":{"type":"string","enum":["resume","passport","rightToWork","insurance","brandedResume","incorporation","other"],"description":"File type. Only `resume` files are sent to the CV parser.","example":"resume"}},"required":["file","type"]}}}},"responses":{"201":{"description":"File uploaded successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"File ID","example":"550e8400-e29b-41d4-a716-446655440000"},"type":{"type":"string","description":"File type","example":"resume"},"filename":{"type":"string","description":"Original filename","example":"resume.pdf"},"mime_type":{"type":"string","description":"MIME type","example":"application/pdf"},"size":{"type":"integer","description":"File size in bytes","example":245832},"created_at":{"type":"string","format":"date-time","description":"Upload timestamp"}},"required":["id","type","filename","mime_type","size","created_at"]}},"required":["status","data"]}}}},"400":{"description":"Bad request - file too large, unsupported format, or missing file","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"File is required"}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/files/attach":{"post":{"summary":"Attach files to entities","description":"Assigns one or more previously uploaded files (created via `POST /api/v1/files`) to one or more records. This is the second step of a two-step workflow: upload the binary first, then attach the returned file ID to a person, company, or project.\n\nEach attachment group links every file in `fileIds` to every entity in `entities` (cartesian product), applying the same optional `type` and `name` overrides. A group with 3 files and 2 entities produces 6 results.\n\n**One link per entity type:** a file holds a single link per entity type, so a group may contain at most one entity of each type (up to 3 entities: one person, one company, one project), and a file ID may appear in only one group. Requests that assign a file to two different entities of the same type return 422.\n\n**Skip vs replace:** a combination whose link already points at the requested entity is returned as `skipped`; a link to a different entity of the same type is replaced; links of other entity types are untouched (partial assignment). When every combination in a group is `skipped`, the `type` and `name` overrides of that group are not applied.\n\n**CV processing:** attaching a file with effective type `resume` to a person triggers CV enrichment of that person. `brandedResume`, `passport`, `rightToWork`, `insurance`, `incorporation`, and `other` are never parsed. Skipped combinations trigger no processing.\n\nOnly files uploaded via the API (`source = api`) can be attached. Files and entities belonging to a different agency return 404. `entityType: \"opportunity\"` is not supported in v1 and returns 422.","tags":["Files"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"attachments":{"type":"array","items":{"type":"object","properties":{"fileIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"IDs of files previously uploaded via `POST /api/v1/files` (1–50 items)","example":["550e8400-e29b-41d4-a716-446655440000"]},"entities":{"type":"array","items":{"type":"object","properties":{"entityType":{"type":"string","enum":["person","company","project"],"description":"Type of the entity to attach the files to","example":"person"},"entityId":{"type":"string","format":"uuid","description":"ID of the entity to attach the files to","example":"a1b2c3d4-e29b-41d4-a716-446655440000"}},"required":["entityType","entityId"]},"minItems":1,"maxItems":3,"description":"Entities to attach the files to (1–3 items, at most one entity per entity type)"},"type":{"type":"string","enum":["resume","passport","rightToWork","insurance","brandedResume","incorporation","other"],"description":"Reclassify the file type. Defaults to the existing type when omitted. Ignored when every combination in the group is `skipped` (nothing newly attached).","example":"resume"},"name":{"type":"string","minLength":1,"maxLength":255,"description":"Rename the file display name (does not affect the stored file). 1–255 characters. Ignored when every combination in the group is `skipped` (nothing newly attached).","example":"John_Doe_Resume_2026.pdf"}},"required":["fileIds","entities"]},"minItems":1,"maxItems":10,"description":"Attachment groups (1–10 items). Each group links every file in `fileIds` to every entity in `entities` (cartesian product), applying the same `type` and `name` overrides. A file ID may appear in only one group."}},"required":["attachments"]}}}},"responses":{"200":{"description":"Outcome for every file × entity combination","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"fileId":{"type":"string","format":"uuid","description":"The file processed","example":"550e8400-e29b-41d4-a716-446655440000"},"entityType":{"type":"string","enum":["person","company","project"],"description":"Target entity type","example":"person"},"entityId":{"type":"string","format":"uuid","description":"Target entity ID","example":"a1b2c3d4-e29b-41d4-a716-446655440000"},"status":{"type":"string","enum":["attached","skipped"],"description":"`attached` — newly linked (an existing link to a different entity of the same type is replaced). `skipped` — the file was already linked to this exact entity.","example":"attached"},"reason":{"type":"string","description":"Present only when `status` is `skipped`","example":"File is already attached to this entity"},"type":{"type":"string","description":"File type after processing","example":"resume"},"name":{"type":"string","description":"Display name after processing","example":"John_Doe_Resume_2026.pdf"}},"required":["fileId","entityType","entityId","status","type","name"]}}},"required":["results"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/files/detach":{"post":{"summary":"Detach files from entities","description":"Unlinks one or more previously attached files from a person, company, or project without deleting the file or its stored content. The file remains available and can be re-attached later via `POST /api/v1/files/attach`.\n\nEach detachment group unlinks the file in `fileId` from every entity in `entities`. A file ID may appear in only one group; requests listing the same file in two groups return 422.\n\n**Exact match only:** a link is removed only when the file is linked to that exact entity. A combination where the file is not linked to the requested entity — including a link that points at a different entity of the same type — is returned as `skipped` with a reason, and the existing link is left untouched.\n\n**Redacted copies:** when a redacted version of the file was created in Atlas, detaching the original also detaches every redacted copy still linked to the same entity, so no derived version of the document stays on the record. `results` reports only the requested file × entity combinations.\n\nDetaching a `resume` file from a person does not undo CV enrichment already applied to that person.\n\nOnly files uploaded via the API (`source = api`) can be detached. Files belonging to a different agency return 404. `entityType: \"opportunity\"` is not supported in v1 and returns 422.","tags":["Files"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"detachments":{"type":"array","items":{"type":"object","properties":{"fileId":{"type":"string","format":"uuid","description":"ID of the file to unlink","example":"550e8400-e29b-41d4-a716-446655440000"},"entities":{"type":"array","items":{"type":"object","properties":{"entityType":{"type":"string","enum":["person","company","project"],"description":"Type of the entity to unlink the file from","example":"person"},"entityId":{"type":"string","format":"uuid","description":"ID of the entity to unlink the file from","example":"a1b2c3d4-e29b-41d4-a716-446655440000"}},"required":["entityType","entityId"]},"minItems":1,"maxItems":3,"description":"Entities to unlink the file from (1–3 items)"}},"required":["fileId","entities"]},"minItems":1,"maxItems":50,"description":"Detachment groups (1–50 items). Each group unlinks the file in `fileId` from every entity in `entities`. A file ID may appear in only one group."}},"required":["detachments"]}}}},"responses":{"200":{"description":"Outcome for every file × entity combination","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"fileId":{"type":"string","format":"uuid","description":"The file processed","example":"550e8400-e29b-41d4-a716-446655440000"},"entityType":{"type":"string","enum":["person","company","project"],"description":"Target entity type","example":"person"},"entityId":{"type":"string","format":"uuid","description":"Target entity ID","example":"a1b2c3d4-e29b-41d4-a716-446655440000"},"status":{"type":"string","enum":["detached","skipped"],"description":"`detached` — the link was removed. `skipped` — the file was not linked to this exact entity.","example":"detached"},"reason":{"type":"string","description":"Present only when `status` is `skipped`","example":"File is not attached to this entity"}},"required":["fileId","entityType","entityId","status"]}}},"required":["results"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/files/{id}/download":{"get":{"summary":"Get a presigned download URL for a file","description":"Returns a short-lived presigned URL the client can use to download a file that belongs to their agency. The URL expires after a few minutes — refetch this endpoint to get a new one. Files belonging to a different agency, and files whose upload never completed, are not accessible and return 404.","tags":["Files"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"ID of the file to download","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Presigned download URL","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"Short-lived presigned URL the client can use to download the file directly from storage.","example":"https://example.s3.amazonaws.com/agency/.../file.pdf?X-Amz-Signature=..."},"expires_at":{"type":"string","format":"date-time","description":"ISO 8601 timestamp at which the presigned URL stops being valid."}},"required":["url","expires_at"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/placements":{"get":{"summary":"List placements","description":"Use this endpoint to retrieve a paginated list of placements (also called hires) within your agency, including their fees.\n\nIn Atlas, a **placement** represents a candidate that has been successfully placed into a role at a client company. This endpoint returns placements scoped to your agency, ordered by creation date.\n\n**What you can filter by:**\n- `type` - the placement type. Options are: `permanent` (a permanent hire with salary information) or `contract` (a contract role).\n- `createdAfter` / `createdBefore` - only return placements created within a specific date range\n- `updatedAfter` / `updatedBefore` - only return placements last modified within a specific date range (ISO 8601). Filters on the `updatedAt` field\n\n**Incremental sync:**\nTo keep an external copy in sync, use `updatedAfter` as a cursor: on each run, request `updatedAfter=<the highest updatedAt you have seen so far>`, page through the results, and persist the maximum `updatedAt` across the rows you receive. Pass that stored value as `updatedAfter` on the next run to fetch only records that changed since. Because the bound is inclusive you may re-receive the boundary row — upsert by `id` to stay idempotent.\n\n**Pagination:**\nResults are returned in pages. Use the `page` and `pageSize` parameters to move through large result sets. The response includes a `pagination` object with `page`, `pageSize`, `total`, and `totalPages`.\n\n**What you get back:**\nEach placement in the list includes its core fields, the top-level `owner` (the consultant the placement is attributed to — this maps to the candidate owner and is the **primary field for counting placements per recruiter**, with `id`, `name`, and `email`), the related project and candidate IDs, and an array of `fees` linked to it. Each fee includes its amount in both the original currency and the agency currency. Fee earners on `fees[].splits[]` are finance/commission attribution only — do not use them for placement counts.\n\n**Deletions (tombstones):**\nSoft-deleted placements are excluded by default. Pass `includeDeleted=true` to also receive deleted placements as tombstones — each carries a populated `deletedAt` (live rows have `deletedAt: null`). Combine `includeDeleted=true` with `updatedAfter` to incrementally pick up deletions: a soft-delete bumps `updatedAt`, so the deleted row resurfaces in the next poll with `deletedAt` set.","tags":["Placements"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["permanent","contract"],"description":"Filter by placement type","example":"permanent"},"required":false,"name":"type","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only placements created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only placements created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only placements updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only placements updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted placements as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"}],"responses":{"200":{"description":"Paginated list of placements","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Placement ID"},"type":{"type":["string","null"],"enum":["permanent","contract"],"description":"Placement type","example":"permanent"},"startDate":{"type":["string","null"],"description":"ISO 8601 placement start date"},"salary":{"type":["object","null"],"properties":{"value":{"type":"string","description":"Amount as a numeric string","example":"15000"},"currency":{"type":"string","description":"ISO 4217 currency code","example":"GBP"}},"required":["value","currency"],"description":"Monetary amount with currency"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Owner user ID"},"name":{"type":"string","description":"Owner name","example":"Jane Smith"},"email":{"type":["string","null"],"description":"Owner email","example":"jane@agency.com"}},"required":["id","name","email"],"description":"Placement owner"},"candidate":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Candidate record ID"},"personId":{"type":["string","null"],"format":"uuid","description":"Person ID"}},"required":["id","personId"],"description":"Candidate reference"},"project":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Project ID"}},"required":["id"],"description":"Project reference"},"client":{"type":"object","properties":{"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company ID"},"name":{"type":["string","null"],"description":"Company name","example":"Acme Corp"}},"required":["id","name"],"description":"Client company (the company making the hire)"},"companyContact":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company contact ID"},"name":{"type":["string","null"],"description":"Contact full name","example":"Bob Client"}},"required":["id","name"],"description":"Hiring contact at the client company"}},"required":["company","companyContact"],"description":"Client company and hiring contact for this placement"},"fees":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee ID"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":["string","null"],"description":"Fee type name","example":"Placement Fee"}},"required":["id","name"],"description":"Fee type reference"},"feeDate":{"type":["string","null"],"description":"Fee date (YYYY-MM-DD)","example":"2025-06-15"},"amount":{"type":["object","null"],"properties":{"value":{"type":"string","description":"Amount as a numeric string","example":"15000"},"currency":{"type":"string","description":"ISO 4217 currency code","example":"GBP"}},"required":["value","currency"],"description":"Monetary amount with currency"},"amountInAgencyCurrency":{"type":["object","null"],"properties":{"value":{"type":"string","description":"Amount as a numeric string","example":"15000"},"currency":{"type":"string","description":"ISO 4217 currency code","example":"GBP"}},"required":["value","currency"],"description":"Monetary amount with currency"},"projectFeeStatus":{"type":"string","enum":["projected","earned","invoiced","paid"],"description":"Fee status","example":"earned"},"notes":{"type":["string","null"],"description":"Fee notes"}},"required":["id","feeType","feeDate","amount","amountInAgencyCurrency","projectFeeStatus","notes"]},"description":"Fees linked to this placement"},"createdAt":{"type":["string","null"],"description":"ISO 8601 created timestamp"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 last updated timestamp"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live placements; populated for tombstones (only returned when `includeDeleted=true`)"}},"required":["id","type","startDate","salary","owner","candidate","project","client","fees","createdAt","updatedAt","deletedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":1},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/placements/{id}":{"get":{"summary":"Get placement details","description":"Use this endpoint to retrieve the full details of a single placement by its ID, including all linked fees and their splits.\n\nThis is the endpoint to use when you already know which placement you want — for example, after finding it via the **List placements** endpoint. It returns everything Atlas knows about the placement in one response.\n\n**What you need to provide:**\nJust the placement's ID in the URL path (e.g. `/api/v1/placements/abc-123`). Placement IDs are UUIDs returned by the List placements endpoint.\n\n**What you get back:**\n\n- **Placement core fields** — type, start date, salary information\n- **owner** — the top-level placement owner (`id`, `name`, `email`), mapped from the candidate owner. This is the **primary recruiter attribution field** for counting placements per recruiter and for \"my placements\" filtering\n- **Candidate context** — candidate name, current role, current company\n- **Nested owners** — `candidate.owner` (the candidate-record owner) and `project.owner` (the job owner) are retained as secondary context; `project.owner` is job/client context, not the placement-count attribution\n- **Fees** — every fee linked to this placement, each one including its amount in both the fee currency and the agency default currency, status, and timestamps for paid/invoiced events\n- **Splits** — for each fee, the breakdown of how it is split between fee earners (consultants), including their share, name, and email. Fee earners are finance/commission attribution only — not placement-count attribution","tags":["Placements"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Placement ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Placement details with fees and splits","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Placement ID"},"type":{"type":["string","null"],"enum":["permanent","contract"],"description":"Placement type","example":"permanent"},"startDate":{"type":["string","null"],"description":"ISO 8601 placement start date"},"salary":{"type":["object","null"],"properties":{"value":{"type":"string","description":"Amount as a numeric string","example":"15000"},"currency":{"type":"string","description":"ISO 4217 currency code","example":"GBP"}},"required":["value","currency"],"description":"Monetary amount with currency"},"bonus":{"type":["string","null"],"description":"Annual bonus (decimal string), denominated in the salary currency","example":"5000"},"feeIncludesBonus":{"type":"boolean","description":"Whether the placement fee includes the bonus"},"commissionRate":{"type":["string","null"],"description":"Commission rate (decimal string)","example":"20"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Owner user ID"},"name":{"type":"string","description":"Owner name","example":"Jane Smith"},"email":{"type":["string","null"],"description":"Owner email","example":"jane@agency.com"}},"required":["id","name","email"],"description":"Placement owner"},"candidate":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Candidate record ID"},"personId":{"type":["string","null"],"format":"uuid","description":"Person ID"},"name":{"type":["string","null"],"description":"Candidate full name","example":"John Doe"},"role":{"type":["string","null"],"description":"Candidate's current role (headline)","example":"Senior Software Engineer"},"company":{"type":["string","null"],"description":"Candidate's current company (headline)","example":"Acme Corp"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"name":{"type":"string","description":"User name","example":"Jane Smith"}},"required":["id","name"],"description":"User reference"}},"required":["id","personId","name","role","company","owner"],"description":"Candidate, with the consultant who owns the candidate record"},"project":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Project ID"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"name":{"type":"string","description":"User name","example":"Jane Smith"}},"required":["id","name"],"description":"User reference"}},"required":["id","owner"],"description":"Project, with the consultant who owns the project"},"client":{"type":"object","properties":{"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company ID"},"name":{"type":["string","null"],"description":"Company name","example":"Acme Corp"}},"required":["id","name"],"description":"Client company (the company making the hire)"},"companyContact":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company contact ID"},"name":{"type":["string","null"],"description":"Contact full name","example":"Bob Client"}},"required":["id","name"],"description":"Hiring contact at the client company"}},"required":["company","companyContact"],"description":"Client company and hiring contact for this placement"},"createdAt":{"type":["string","null"],"description":"ISO 8601 created timestamp"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 last updated timestamp"},"fees":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee ID"},"placementId":{"type":["string","null"],"format":"uuid","description":"Placement ID"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":["string","null"],"description":"Fee type name","example":"Placement Fee"}},"required":["id","name"],"description":"Fee type reference"},"feeDate":{"type":["string","null"],"description":"Fee date (YYYY-MM-DD)","example":"2025-06-15"},"amount":{"type":["object","null"],"properties":{"value":{"type":"string","description":"Amount as a numeric string","example":"15000"},"currency":{"type":"string","description":"ISO 4217 currency code","example":"GBP"}},"required":["value","currency"],"description":"Monetary amount with currency"},"amountInAgencyCurrency":{"type":["object","null"],"properties":{"value":{"type":"string","description":"Amount as a numeric string","example":"15000"},"currency":{"type":"string","description":"ISO 4217 currency code","example":"GBP"}},"required":["value","currency"],"description":"Monetary amount with currency"},"projectFeeStatus":{"type":"string","enum":["projected","earned","invoiced","paid"],"description":"Fee status","example":"earned"},"notes":{"type":["string","null"],"description":"Fee notes"},"externalInvoiceNumber":{"type":["string","null"],"description":"External invoice number"},"invoiceAccountCode":{"type":["string","null"],"description":"Accounting code"},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"name":{"type":"string","description":"User name","example":"Jane Smith"}},"required":["id","name"],"description":"User reference"},"paidAt":{"type":["string","null"],"description":"ISO 8601 paid timestamp"},"invoicedAt":{"type":["string","null"],"description":"ISO 8601 invoiced timestamp"},"createdAt":{"type":["string","null"],"description":"ISO 8601 created timestamp"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 last updated timestamp"},"splits":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Split ID"},"feeEarner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee earner user ID"},"name":{"type":"string","description":"Fee earner name","example":"Jane Smith"},"email":{"type":["string","null"],"description":"Fee earner email","example":"jane@agency.com"}},"required":["id","name","email"],"description":"Fee earner user reference"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":["string","null"],"description":"Fee type name","example":"Placement Fee"}},"required":["id","name"],"description":"Fee type reference"},"share":{"type":["string","null"],"description":"Split percentage with 2 decimal places","example":"60.00"},"notes":{"type":["string","null"],"description":"Split notes"}},"required":["id","feeEarner","feeType","share","notes"]},"description":"Fee splits"}},"required":["id","placementId","feeType","feeDate","amount","amountInAgencyCurrency","projectFeeStatus","notes","externalInvoiceNumber","invoiceAccountCode","createdBy","paidAt","invoicedAt","createdAt","updatedAt","splits"]},"description":"Fees linked to this placement"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Placement custom attribute values"}},"required":["id","type","startDate","salary","bonus","feeIncludesBonus","commissionRate","owner","candidate","project","client","createdAt","updatedAt","fees","customAttributes"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Placement not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"patch":{"summary":"Update a placement","description":"Use this endpoint to partially update an existing placement. Only the fields present in the request body are changed — omitted fields are left untouched.\n\n**What you can update:**\n- `startDate` — ISO 8601 datetime, or `null` to clear it\n- `salary` — a `{ value, currency }` object, or `null` to clear both. This maps to the `salary` object returned by the GET endpoints\n- `bonus` — a decimal string denominated in the salary currency (there is no separate bonus currency), or `null` to clear it\n- `feeIncludesBonus` — boolean; `null` is rejected\n- `commissionRate` — a decimal string, or `null` to clear it. Unlike creation, no default is applied — the literal value is written\n- `companyContactId` — the hiring contact at the client company. It must belong to your agency, and to the project's company when the project has one; `null` clears it\n- `customAttributes` — **full replace**: every existing value not present in the payload is removed. Omit the field to leave values untouched\n- `addFeeIds` / `removeFeeIds` — link fees from the placement's project to this placement, or unlink fees currently linked to it\n\n**Permissions:**\nOnly the placement owner or an admin (a user with the `Agency.update` or `Placement.update` scope) can update a placement. The acting user is the one tied to your API key — a key whose user is neither returns `403`, as does a key with no user at all.\n\n**Behaviour notes:**\n- The body must be sent with `Content-Type: application/json` — any other content type is rejected with `415` so a payload is never silently discarded\n- A request with no body at all is rejected with `422`. An explicit empty object (`{}`) is the documented no-op: it returns `200` and writes nothing\n- Soft-deleted placements return `404` — they cannot be updated\n- A body that changes nothing still returns `200`, but no `placement.updated` webhook is emitted\n- A change to a scalar field (`startDate`, `salary`, `bonus`, `feeIncludesBonus`, `commissionRate`, `companyContactId`) emits the `placement.updated` webhook with the changed fields. Fee link/unlink and custom-attribute changes do **not** emit a webhook\n\n**What you get back:**\nThe full updated placement in the same shape as **Get placement details**, including nested `salary`, `bonus`, `feeIncludesBonus`, `commissionRate`, `candidate`, `project`, `client`, `fees` and `customAttributes`.","tags":["Placements"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Placement ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"startDate":{"type":["string","null"],"format":"date-time","description":"Placement start date (ISO 8601 datetime). `null` clears it","example":"2026-09-01T00:00:00Z"},"salary":{"type":["object","null"],"properties":{"value":{"type":"string","pattern":"^(0|[1-9]\\d{0,17})(\\.\\d{1,2})?$","description":"Amount (decimal string, numeric(20,2))","example":"25000.00"},"currency":{"type":"string","enum":["USD","EUR","JPY","GBP","AUD","CAD","CHF","CNY","HKD","NZD","SEK","NOK","MXN","SGD","RUB","ZAR","TRY","BRL","INR","KRW","DKK","PLN","ILS","HUF","CZK","RON","THB","MYR","IDR","VND","PHP","SAR","AED","QAR","KWD","JOD","CLP","COP","PEN","ARS","UYU","CRC","PKR","BDT","LKR","EGP","NGN","TWD","KES","GHS","UGX","TZS","MAD","BWP","BGN","UAH","KZT","GEL","ISK","BHD","OMR"],"description":"Currency code","example":"USD"}},"required":["value","currency"],"description":"Annual salary. Both `value` and `currency` are required; `null` clears both"},"bonus":{"type":["string","null"],"pattern":"^(0|[1-9]\\d{0,17})(\\.\\d{1,2})?$","description":"Annual bonus (decimal string), denominated in the salary currency. `null` clears it","example":"5000.00"},"feeIncludesBonus":{"type":"boolean","description":"Whether the placement fee includes the bonus. Not nullable"},"commissionRate":{"type":["string","null"],"pattern":"^(0|[1-9]\\d{0,17})(\\.\\d{1,2})?$","description":"Commission rate (decimal string). `null` clears it","example":"20.00"},"companyContactId":{"type":["string","null"],"format":"uuid","description":"Hiring contact at the client company. Must belong to your agency (and to the project's company when the project has one). `null` clears it"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"customAttributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID — must belong to the agency at the correct scope."},"optionId":{"type":["string","null"],"format":"uuid","description":"Required for `options`-type attributes — set to the chosen option's UUID. Mutually exclusive with `value`. For multi-select (`multipleValues: true`), repeat the entry once per chosen optionId."},"value":{"anyOf":[{"type":"string","minLength":1,"maxLength":16384},{"type":"number"},{"type":"null"}],"description":"Attribute value. Shape depends on the attribute `type`:\n- `text_line` / `text_block`: non-empty string (trimmed, max 16384 chars).\n- `integer` / `number_input`: JSON number, integer only, must fit Postgres int32 (-2147483648..2147483647).\n- `date`: string `YYYY-MM-DD`, calendar-validated.\n- `options`: do not send `value` — use `optionId` instead.\nMutually exclusive with `optionId`. Sending neither is rejected (422).","example":"Some text value"}},"required":["customAttributeId"],"description":"Custom attribute value"},"maxItems":100,"description":"Placement custom attribute values. Full replace: every existing value not present in the payload is removed. Omit the field to leave values untouched"},"addFeeIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"Fee IDs to link to this placement. Fees must belong to the placement's project and not be linked to another placement"},"removeFeeIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"Fee IDs to unlink from this placement. Fees must currently be linked to this placement"}},"additionalProperties":false}}}},"responses":{"200":{"description":"The updated placement, in the same shape as Get placement details","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Placement ID"},"type":{"type":["string","null"],"enum":["permanent","contract"],"description":"Placement type","example":"permanent"},"startDate":{"type":["string","null"],"description":"ISO 8601 placement start date"},"salary":{"type":["object","null"],"properties":{"value":{"type":"string","description":"Amount as a numeric string","example":"15000"},"currency":{"type":"string","description":"ISO 4217 currency code","example":"GBP"}},"required":["value","currency"],"description":"Monetary amount with currency"},"bonus":{"type":["string","null"],"description":"Annual bonus (decimal string), denominated in the salary currency","example":"5000"},"feeIncludesBonus":{"type":"boolean","description":"Whether the placement fee includes the bonus"},"commissionRate":{"type":["string","null"],"description":"Commission rate (decimal string)","example":"20"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Owner user ID"},"name":{"type":"string","description":"Owner name","example":"Jane Smith"},"email":{"type":["string","null"],"description":"Owner email","example":"jane@agency.com"}},"required":["id","name","email"],"description":"Placement owner"},"candidate":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Candidate record ID"},"personId":{"type":["string","null"],"format":"uuid","description":"Person ID"},"name":{"type":["string","null"],"description":"Candidate full name","example":"John Doe"},"role":{"type":["string","null"],"description":"Candidate's current role (headline)","example":"Senior Software Engineer"},"company":{"type":["string","null"],"description":"Candidate's current company (headline)","example":"Acme Corp"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"name":{"type":"string","description":"User name","example":"Jane Smith"}},"required":["id","name"],"description":"User reference"}},"required":["id","personId","name","role","company","owner"],"description":"Candidate, with the consultant who owns the candidate record"},"project":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Project ID"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"name":{"type":"string","description":"User name","example":"Jane Smith"}},"required":["id","name"],"description":"User reference"}},"required":["id","owner"],"description":"Project, with the consultant who owns the project"},"client":{"type":"object","properties":{"company":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company ID"},"name":{"type":["string","null"],"description":"Company name","example":"Acme Corp"}},"required":["id","name"],"description":"Client company (the company making the hire)"},"companyContact":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company contact ID"},"name":{"type":["string","null"],"description":"Contact full name","example":"Bob Client"}},"required":["id","name"],"description":"Hiring contact at the client company"}},"required":["company","companyContact"],"description":"Client company and hiring contact for this placement"},"createdAt":{"type":["string","null"],"description":"ISO 8601 created timestamp"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 last updated timestamp"},"fees":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee ID"},"placementId":{"type":["string","null"],"format":"uuid","description":"Placement ID"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":["string","null"],"description":"Fee type name","example":"Placement Fee"}},"required":["id","name"],"description":"Fee type reference"},"feeDate":{"type":["string","null"],"description":"Fee date (YYYY-MM-DD)","example":"2025-06-15"},"amount":{"type":["object","null"],"properties":{"value":{"type":"string","description":"Amount as a numeric string","example":"15000"},"currency":{"type":"string","description":"ISO 4217 currency code","example":"GBP"}},"required":["value","currency"],"description":"Monetary amount with currency"},"amountInAgencyCurrency":{"type":["object","null"],"properties":{"value":{"type":"string","description":"Amount as a numeric string","example":"15000"},"currency":{"type":"string","description":"ISO 4217 currency code","example":"GBP"}},"required":["value","currency"],"description":"Monetary amount with currency"},"projectFeeStatus":{"type":"string","enum":["projected","earned","invoiced","paid"],"description":"Fee status","example":"earned"},"notes":{"type":["string","null"],"description":"Fee notes"},"externalInvoiceNumber":{"type":["string","null"],"description":"External invoice number"},"invoiceAccountCode":{"type":["string","null"],"description":"Accounting code"},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"name":{"type":"string","description":"User name","example":"Jane Smith"}},"required":["id","name"],"description":"User reference"},"paidAt":{"type":["string","null"],"description":"ISO 8601 paid timestamp"},"invoicedAt":{"type":["string","null"],"description":"ISO 8601 invoiced timestamp"},"createdAt":{"type":["string","null"],"description":"ISO 8601 created timestamp"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 last updated timestamp"},"splits":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Split ID"},"feeEarner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee earner user ID"},"name":{"type":"string","description":"Fee earner name","example":"Jane Smith"},"email":{"type":["string","null"],"description":"Fee earner email","example":"jane@agency.com"}},"required":["id","name","email"],"description":"Fee earner user reference"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":["string","null"],"description":"Fee type name","example":"Placement Fee"}},"required":["id","name"],"description":"Fee type reference"},"share":{"type":["string","null"],"description":"Split percentage with 2 decimal places","example":"60.00"},"notes":{"type":["string","null"],"description":"Split notes"}},"required":["id","feeEarner","feeType","share","notes"]},"description":"Fee splits"}},"required":["id","placementId","feeType","feeDate","amount","amountInAgencyCurrency","projectFeeStatus","notes","externalInvoiceNumber","invoiceAccountCode","createdBy","paidAt","invoicedAt","createdAt","updatedAt","splits"]},"description":"Fees linked to this placement"},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string","format":"uuid","description":"Custom attribute definition ID"},"attributeName":{"type":["string","null"],"description":"Attribute name"},"attributeType":{"type":["string","null"],"enum":["options","text_block","text_line","number_input","integer","date"],"description":"Attribute type — drives the shape of each entry in `values`."},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Selected option ID"},"optionValue":{"type":["string","null"],"description":"Display value of the option"}},"required":["optionId","optionValue"]}],"description":"A single value entry. Shape depends on `attributeType`:\n- `text_line` / `text_block` → string\n- `integer` / `number_input` → number\n- `date` → ISO `YYYY-MM-DD` string\n- `options` → `{ optionId, optionValue }` object"},"description":"All values recorded for this attribute. For single-value attributes the array has one entry; for multi-select `options` attributes it may have several."}},"required":["attributeId","attributeName","attributeType","values"]},"description":"Placement custom attribute values"}},"required":["id","type","startDate","salary","bonus","feeIncludesBonus","commissionRate","owner","candidate","project","client","createdAt","updatedAt","fees","customAttributes"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"403":{"description":"Forbidden — the API key's user is neither the placement owner nor an admin, or the key has no user. A token lacking FINANCE read-write scope returns 401","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Only admins or placement owners can update or delete a placement"}}}},"404":{"description":"Placement not found (unknown, soft-deleted or cross-agency id), or company contact not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"415":{"description":"Unsupported Media Type. The request body must be sent with `Content-Type: application/json` (charset parameters are accepted). Other content types are rejected up front to avoid silently discarding the payload.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unsupported Media Type. Use Content-Type: application/json."}}}},"422":{"description":"Validation error, or the request carried no body at all. Send an explicit empty JSON object (`{}`) for a no-op update.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/fees":{"get":{"summary":"List fees","description":"Use this endpoint to retrieve a paginated list of fees for the authenticated agency.\n\nA **fee** represents a financial charge tied to a project — typically a placement fee earned when a candidate is successfully hired. Each fee tracks the amount, currency, status, and payment details, and may include one or more splits showing how the revenue is distributed among fee earners.\n\n**What you can filter by:**\n- `projectIds` — comma-separated project UUIDs to limit fees to specific projects\n- `createdAfter` / `createdBefore` — only return fees created within a specific ISO 8601 date range\n- `updatedAfter` / `updatedBefore` — only return fees last modified within a specific ISO 8601 date range. Filters on the `updatedAt` field\n\n**Incremental sync:**\nTo keep an external copy in sync, use `updatedAfter` as a cursor: on each run, request `updatedAfter=<the highest updatedAt you have seen so far>`, page through the results, and persist the maximum `updatedAt` across the rows you receive. Pass that stored value as `updatedAfter` on the next run to fetch only records that changed since. Because the bound is inclusive you may re-receive the boundary row — upsert by `id` to stay idempotent.\n\n**Pagination & ordering:**\nResults are returned in pages, ordered by creation date. Pass `order=asc` for oldest first, or `order=desc` (default) for newest first. Use the `page` and `pageSize` parameters to move through large result sets. The response includes a top-level `total` so you know how many results exist in total.\n\n**Deletions (tombstones):**\nSoft-deleted fees are excluded by default. Pass `includeDeleted=true` to also receive deleted fees as tombstones — each carries a populated `deletedAt` (live rows have `deletedAt: null`). Combine `includeDeleted=true` with `updatedAfter` to incrementally pick up deletions: a soft-delete bumps `updatedAt`, so the deleted row resurfaces in the next poll with `deletedAt` set. A deleted fee tombstone also returns the splits it had at deletion time, each carrying its own `deletedAt`; live fees only ever return their live splits.\n\n**What you get back:**\nAn array of fee objects, each with its full set of fee splits and earner identity inlined.","tags":["Fees"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Comma-separated project UUIDs to filter fees by","example":"550e8400-e29b-41d4-a716-446655440000,660e8400-e29b-41d4-a716-446655440001"},"required":false,"name":"projectIds","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only fees created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only fees created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only fees updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only fees updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted fees as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"string","enum":["asc","desc"],"default":"desc","description":"Sort order by creation date — `desc` (newest first, default) or `asc` (oldest first)","example":"desc"},"required":false,"name":"order","in":"query"}],"responses":{"200":{"description":"Paginated list of fees","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee ID"},"projectId":{"type":"string","format":"uuid","description":"Associated project ID"},"personId":{"type":["string","null"],"format":"uuid","description":"Associated person/candidate ID"},"placementId":{"type":["string","null"],"format":"uuid","description":"Associated placement ID"},"createdById":{"type":"string","format":"uuid","description":"User ID who created the fee"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":"string","description":"Fee type name","example":"Placement Fee"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 — when the fee type was soft-deleted; null when active"}},"required":["id","name","deletedAt"],"description":"Associated fee type; null when none is assigned"},"feeDate":{"type":["string","null"],"description":"Effective date of the fee (YYYY-MM-DD)","example":"2025-06-15"},"amount":{"type":["string","null"],"description":"Fee amount as decimal string","example":"15000.00"},"defaultAmount":{"type":["string","null"],"description":"Default/original fee amount as decimal string","example":"15000.00"},"currency":{"type":"string","description":"Fee currency code","example":"GBP"},"defaultCurrency":{"type":"string","description":"Agency base currency code","example":"GBP"},"projectFeeStatus":{"type":"string","enum":["projected","earned","invoiced","paid"],"description":"Fee lifecycle status","example":"earned"},"notes":{"type":["string","null"],"description":"Fee notes"},"externalInvoiceNumber":{"type":["string","null"],"description":"External invoice number"},"invoiceAccountCode":{"type":["string","null"],"description":"Accounting account code for invoices"},"paidAt":{"type":["string","null"],"description":"ISO 8601 — when the fee was paid"},"invoicedAt":{"type":["string","null"],"description":"ISO 8601 — when the fee was invoiced"},"createdAt":{"type":["string","null"],"description":"ISO 8601 — creation date"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 — last update date"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live fees; populated for tombstones (only returned when `includeDeleted=true`)"},"splits":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Split ID"},"feeEarner":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee earner (user) ID"},"name":{"type":["string","null"],"description":"Fee earner display name","example":"Jane Smith"},"email":{"type":["string","null"],"description":"Fee earner email address","example":"jane@agency.com"}},"required":["id","name","email"],"description":"User who earns this split"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":"string","description":"Fee type name","example":"Placement Fee"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 — when the fee type was soft-deleted; null when active"}},"required":["id","name","deletedAt"],"description":"Associated fee type; null when none is assigned"},"share":{"type":["string","null"],"description":"Share percentage as decimal string","example":"60.00"},"notes":{"type":["string","null"],"description":"Notes on this split"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live splits. Only populated for splits of a deleted fee tombstone (returned when `includeDeleted=true`)"}},"required":["id","feeEarner","feeType","share","notes","deletedAt"]},"description":"Fee splits across earners — may be empty"}},"required":["id","projectId","personId","placementId","createdById","feeType","feeDate","amount","defaultAmount","currency","defaultCurrency","projectFeeStatus","notes","externalInvoiceNumber","invoiceAccountCode","paidAt","invoicedAt","createdAt","updatedAt","deletedAt","splits"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Results per page","example":25},"total":{"type":"integer","description":"Total number of matching fees","example":42},"hasMore":{"type":"boolean","description":"Whether more pages of results are available","example":true}},"required":["page","pageSize","total","hasMore"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/fees/{id}":{"get":{"summary":"Get fee details","description":"Use this endpoint to retrieve the full details of a single fee by its ID.\n\nA **fee** represents a financial charge tied to a project — typically a placement fee earned when a candidate is successfully hired. Fees progress through four statuses: `projected` (expected but not yet confirmed), `earned` (confirmed/won), `invoiced` (invoice sent to client), and `paid` (payment received).\n\nEach fee may have one or more **splits**, which define how the fee revenue is distributed among individual fee earners (users) within the agency. Each split specifies a percentage share and can carry its own fee type and notes. If no splits have been defined for a fee, the splits array will be empty.\n\nAll monetary amounts (`amount`, `defaultAmount`, and split `share`) are returned as decimal strings to preserve precision.\n\n**What you need to provide:**\n- The fee's ID in the URL path\n\n**What you get back:**\nThe fee record with all of its splits.","tags":["Fees"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Fee ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Fee details","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee ID"},"projectId":{"type":"string","format":"uuid","description":"Associated project ID"},"personId":{"type":["string","null"],"format":"uuid","description":"Associated person/candidate ID"},"placementId":{"type":["string","null"],"format":"uuid","description":"Associated placement ID"},"createdById":{"type":"string","format":"uuid","description":"User ID who created the fee"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":"string","description":"Fee type name","example":"Placement Fee"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 — when the fee type was soft-deleted; null when active"}},"required":["id","name","deletedAt"],"description":"Associated fee type; null when none is assigned"},"feeDate":{"type":["string","null"],"description":"Effective date of the fee (YYYY-MM-DD)","example":"2025-06-15"},"amount":{"type":["string","null"],"description":"Fee amount as decimal string","example":"15000.00"},"defaultAmount":{"type":["string","null"],"description":"Default/original fee amount as decimal string","example":"15000.00"},"currency":{"type":"string","description":"Fee currency code","example":"GBP"},"defaultCurrency":{"type":"string","description":"Agency base currency code","example":"GBP"},"projectFeeStatus":{"type":"string","enum":["projected","earned","invoiced","paid"],"description":"Fee lifecycle status","example":"earned"},"notes":{"type":["string","null"],"description":"Fee notes"},"externalInvoiceNumber":{"type":["string","null"],"description":"External invoice number"},"invoiceAccountCode":{"type":["string","null"],"description":"Accounting account code for invoices"},"paidAt":{"type":["string","null"],"description":"ISO 8601 — when the fee was paid"},"invoicedAt":{"type":["string","null"],"description":"ISO 8601 — when the fee was invoiced"},"createdAt":{"type":["string","null"],"description":"ISO 8601 — creation date"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 — last update date"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live fees; populated for tombstones (only returned when `includeDeleted=true`)"},"splits":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Split ID"},"feeEarner":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee earner (user) ID"},"name":{"type":["string","null"],"description":"Fee earner display name","example":"Jane Smith"},"email":{"type":["string","null"],"description":"Fee earner email address","example":"jane@agency.com"}},"required":["id","name","email"],"description":"User who earns this split"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":"string","description":"Fee type name","example":"Placement Fee"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 — when the fee type was soft-deleted; null when active"}},"required":["id","name","deletedAt"],"description":"Associated fee type; null when none is assigned"},"share":{"type":["string","null"],"description":"Share percentage as decimal string","example":"60.00"},"notes":{"type":["string","null"],"description":"Notes on this split"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live splits. Only populated for splits of a deleted fee tombstone (returned when `includeDeleted=true`)"}},"required":["id","feeEarner","feeType","share","notes","deletedAt"]},"description":"Fee splits across earners — may be empty"}},"required":["id","projectId","personId","placementId","createdById","feeType","feeDate","amount","defaultAmount","currency","defaultCurrency","projectFeeStatus","notes","externalInvoiceNumber","invoiceAccountCode","paidAt","invoicedAt","createdAt","updatedAt","deletedAt","splits"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Fee not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"patch":{"summary":"Update a fee","description":"Use this endpoint to partially update an existing fee. Omitted fields are left untouched.\n\n**Amount & currency:** `amount` is a decimal string (matching what the read endpoints return). Changing `amount`, `currency` or `feeDate` recomputes `defaultAmount` in the agency currency using the FX rate for the fee date.\n\n**Status:** fees progress `projected` → `earned` → `invoiced` → `paid`. Setting `projected` is rejected with a `422` when the fee would still be linked to a placement after the update — including a placement picked up by relinking `personId` in the same request (such fees are at least `earned`). A change to `paid` stamps `paidAt`; a change to `invoiced` stamps `invoicedAt`.\n\n**Clearing text fields:** `notes` and `invoiceAccountCode` are cleared by sending `null`. Empty and whitespace-only strings are rejected with a `422` rather than silently stored — otherwise the value written (`\"\"`) and the value read back (`null`) would disagree.\n\n**Relinking:** `personId` requires `projectId`, which must match the fee’s own project (a fee cannot be moved to another project — `422` otherwise), and rewires `placementId` to that candidate’s placement on the project (or `null` when there is none). Passing `personId: null` detaches the person and the placement.\n\n**Splits:** the `splits` array is a full replacement, not a merge. `[]` removes every split. In a non-empty array, items with an `id` update that split, items without an `id` create a new one, and any existing split not listed is removed. Shares must total exactly 100, and the same split `id` may appear at most once per request (`422` otherwise).\n\n**What you get back:**\nThe full updated fee record with all of its splits — the same shape as `GET /api/v1/fees/{id}`.","tags":["Fees"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Fee ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"amount":{"type":"string","pattern":"^(0|[1-9]\\d{0,17})(\\.\\d{1,2})?$","description":"Fee amount as a decimal string. Changing `amount`, `currency` or `feeDate` recomputes `defaultAmount` in the agency currency using the FX rate for the fee date.","example":"15000.00"},"currency":{"type":"string","enum":["USD","EUR","JPY","GBP","AUD","CAD","CHF","CNY","HKD","NZD","SEK","NOK","MXN","SGD","RUB","ZAR","TRY","BRL","INR","KRW","DKK","PLN","ILS","HUF","CZK","RON","THB","MYR","IDR","VND","PHP","SAR","AED","QAR","KWD","JOD","CLP","COP","PEN","ARS","UYU","CRC","PKR","BDT","LKR","EGP","NGN","TWD","KES","GHS","UGX","TZS","MAD","BWP","BGN","UAH","KZT","GEL","ISK","BHD","OMR"],"description":"Fee currency code (ISO 4217)","example":"GBP"},"feeDate":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Effective date of the fee (YYYY-MM-DD)","example":"2025-06-15"},"projectFeeStatus":{"type":"string","enum":["projected","earned","invoiced","paid"],"description":"Fee lifecycle status. `projected` is rejected (422) when the fee would still be linked to a placement after this update (including a placement picked up via `personId` relinking) — use `earned`. A change to `paid` stamps `paidAt`; a change to `invoiced` stamps `invoicedAt`.","example":"earned"},"notes":{"type":["string","null"],"minLength":1,"maxLength":16384,"description":"Fee notes. `null` clears the field; an empty or whitespace-only string is rejected (422)."},"feeTypeId":{"type":"string","format":"uuid","description":"Fee type ID — must belong to the agency","example":"990e8400-e29b-41d4-a716-446655440004"},"personId":{"type":["string","null"],"format":"uuid","description":"Relinks the fee to a person. Requires `projectId` and relinks `placementId` to that candidate’s placement (or `null` when the person has no placement on the project). `null` detaches the person and the placement.","example":"aa0e8400-e29b-41d4-a716-446655440005"},"projectId":{"type":"string","format":"uuid","description":"Project used to resolve the candidate placement when `personId` is set. Must match the fee's own project — a fee cannot be moved to another project (422 otherwise).","example":"550e8400-e29b-41d4-a716-446655440000"},"invoiceAccountCode":{"type":["string","null"],"minLength":1,"maxLength":255,"description":"Accounting account code for invoices. `null` clears the field; an empty or whitespace-only string is rejected (422)."},"splits":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"ID of an existing split to update. Omit to create a new split.","example":"770e8400-e29b-41d4-a716-446655440002"},"feeEarnerId":{"type":"string","format":"uuid","description":"User (fee earner) receiving this share","example":"880e8400-e29b-41d4-a716-446655440003"},"feeTypeId":{"type":"string","format":"uuid","description":"Fee type of this split — must belong to the agency","example":"990e8400-e29b-41d4-a716-446655440004"},"share":{"type":"string","pattern":"^(0|[1-9]\\d{0,2})(\\.\\d{1,2})?$","description":"Percentage share as a decimal string. All shares in the request must total exactly 100.","example":"60.00"},"notes":{"type":"string","minLength":1,"maxLength":16384,"description":"Notes on this split. Omit to leave unchanged; empty strings are rejected (422)."}},"required":["feeEarnerId","feeTypeId","share"],"additionalProperties":false},"maxItems":100,"description":"Full replacement of the fee splits — not a merge. `[]` removes every split. In a non-empty array, items with an `id` update that split, items without create a new one, and any existing split not listed is removed. Shares must total exactly 100."}},"additionalProperties":false}}}},"responses":{"200":{"description":"Fee updated","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee ID"},"projectId":{"type":"string","format":"uuid","description":"Associated project ID"},"personId":{"type":["string","null"],"format":"uuid","description":"Associated person/candidate ID"},"placementId":{"type":["string","null"],"format":"uuid","description":"Associated placement ID"},"createdById":{"type":"string","format":"uuid","description":"User ID who created the fee"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":"string","description":"Fee type name","example":"Placement Fee"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 — when the fee type was soft-deleted; null when active"}},"required":["id","name","deletedAt"],"description":"Associated fee type; null when none is assigned"},"feeDate":{"type":["string","null"],"description":"Effective date of the fee (YYYY-MM-DD)","example":"2025-06-15"},"amount":{"type":["string","null"],"description":"Fee amount as decimal string","example":"15000.00"},"defaultAmount":{"type":["string","null"],"description":"Default/original fee amount as decimal string","example":"15000.00"},"currency":{"type":"string","description":"Fee currency code","example":"GBP"},"defaultCurrency":{"type":"string","description":"Agency base currency code","example":"GBP"},"projectFeeStatus":{"type":"string","enum":["projected","earned","invoiced","paid"],"description":"Fee lifecycle status","example":"earned"},"notes":{"type":["string","null"],"description":"Fee notes"},"externalInvoiceNumber":{"type":["string","null"],"description":"External invoice number"},"invoiceAccountCode":{"type":["string","null"],"description":"Accounting account code for invoices"},"paidAt":{"type":["string","null"],"description":"ISO 8601 — when the fee was paid"},"invoicedAt":{"type":["string","null"],"description":"ISO 8601 — when the fee was invoiced"},"createdAt":{"type":["string","null"],"description":"ISO 8601 — creation date"},"updatedAt":{"type":["string","null"],"description":"ISO 8601 — last update date"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live fees; populated for tombstones (only returned when `includeDeleted=true`)"},"splits":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Split ID"},"feeEarner":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee earner (user) ID"},"name":{"type":["string","null"],"description":"Fee earner display name","example":"Jane Smith"},"email":{"type":["string","null"],"description":"Fee earner email address","example":"jane@agency.com"}},"required":["id","name","email"],"description":"User who earns this split"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":"string","description":"Fee type name","example":"Placement Fee"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 — when the fee type was soft-deleted; null when active"}},"required":["id","name","deletedAt"],"description":"Associated fee type; null when none is assigned"},"share":{"type":["string","null"],"description":"Share percentage as decimal string","example":"60.00"},"notes":{"type":["string","null"],"description":"Notes on this split"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live splits. Only populated for splits of a deleted fee tombstone (returned when `includeDeleted=true`)"}},"required":["id","feeEarner","feeType","share","notes","deletedAt"]},"description":"Fee splits across earners — may be empty"}},"required":["id","projectId","personId","placementId","createdById","feeType","feeDate","amount","defaultAmount","currency","defaultCurrency","projectFeeStatus","notes","externalInvoiceNumber","invoiceAccountCode","paidAt","invoicedAt","createdAt","updatedAt","deletedAt","splits"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Fee, fee type, person, project, or fee earner not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/contracts":{"get":{"summary":"List contracts","description":"Use this endpoint to retrieve a paginated list of contracts within your agency, ordered by start date (most recent first).\n\nA **contract** represents an ongoing engagement where a contractor works for a client company on a project. Each row is a lightweight summary — use **Get contract details** (`GET /api/v1/contracts/{id}`) to drill into a single contract's rates, contacts, attributions, and allowances.\n\n**Contract hires vs permanent hires:**\nContract financials and schedule live on the contract, not on the placement. Use **this endpoint** (plus the detail endpoint) for contract engagements — it is the only source of `endDate` and of the charge/pay rates. Keep using `GET /api/v1/placements` for permanent hires (salary) and for fee attribution on both hire types. `GET /api/v1/placements` does not embed contract rates or contract end dates.\n\n**What you can filter by (filters combine with AND semantics):**\n- `status` - one or more of `draft`, `scheduled`, `active`, `ended`, `terminated`, `cancelled` (comma-separated)\n- `projectId` / `contractorId` / `clientCompanyId` - the linked project, contractor person, or end client company\n- `ownerEmail` - one or more contract owner emails (comma-separated)\n- `startDateFrom` / `startDateTo` - only contracts whose start date falls in this range (YYYY-MM-DD, both bounds inclusive)\n- `createdAfter` / `createdBefore` - only contracts created within a specific date range\n- `updatedAfter` / `updatedBefore` - only contracts last modified within a specific date range (ISO 8601). Filters on the `updatedAt` field\n\n**Incremental sync:**\nTo keep an external copy in sync, use `updatedAfter` as a cursor: on each run, request `updatedAfter=<the highest updatedAt you have seen so far>`, page through the results, and persist the maximum `updatedAt` across the rows you receive. Pass that stored value as `updatedAfter` on the next run to fetch only records that changed since. Because the bound is inclusive you may re-receive the boundary row — upsert by `id` to stay idempotent. Editing a contract's end date or any of its rates bumps `updatedAt`, so rate changes surface on the next poll; fetch `GET /api/v1/contracts/{id}` for the rows whose rates you need.\n\n**Pagination:**\nResults are returned in pages. Use the `page` and `pageSize` parameters to move through large result sets. The response includes a `pagination` object with `page`, `pageSize`, `total`, and `totalPages`.\n\n**What you get back:**\nEach contract in the list includes its status and schedule (`startDate`, `endDate`, derived `durationDays`), references to the client company, project, contractor, and owner, and the four pre-calculated financials (`weeklyRevenue`, `weeklyGp`, `monthlyRevenue`, `monthlyGp`) in the agency base currency — `null` for contracts with no rates yet. Rates are intentionally not on the list rows; read them from the detail endpoint.\n\n**Deletions (tombstones):**\nSoft-deleted contracts are excluded by default. Pass `includeDeleted=true` to also receive deleted contracts as tombstones — each carries a populated `deletedAt` (live rows have `deletedAt: null`). Combine `includeDeleted=true` with `updatedAfter` to incrementally pick up deletions: a soft-delete bumps `updatedAt`, so the deleted row resurfaces in the next poll with `deletedAt` set.","tags":["Contracts"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Comma-separated contract statuses to filter by. Options: draft, scheduled, active, ended, terminated, cancelled","example":"active,scheduled"},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter by linked project ID","example":"550e8400-e29b-41d4-a716-446655440001"},"required":false,"name":"projectId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter by contractor (person) ID","example":"550e8400-e29b-41d4-a716-446655440002"},"required":false,"name":"contractorId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter by end client company ID","example":"550e8400-e29b-41d4-a716-446655440003"},"required":false,"name":"clientCompanyId","in":"query"},{"schema":{"type":"string","description":"Comma-separated contract owner emails","example":"owner1@agency.com,owner2@agency.com"},"required":false,"name":"ownerEmail","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Only contracts with startDate on or after this date (YYYY-MM-DD, inclusive)","example":"2026-01-01"},"required":false,"name":"startDateFrom","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Only contracts with startDate on or before this date (YYYY-MM-DD, inclusive)","example":"2026-12-31"},"required":false,"name":"startDateTo","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only contracts created after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-01-01"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only contracts created before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-01-01"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only contracts updated after this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering from the start of that UTC day)","example":"2025-06-01"},"required":false,"name":"updatedAfter","in":"query"},{"schema":{"anyOf":[{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},{"type":"string","format":"date-time"}],"description":"Only contracts updated before this point (inclusive). Accepts an ISO 8601 datetime or a date-only YYYY-MM-DD value (covering through the end of that UTC day)","example":"2026-06-01"},"required":false,"name":"updatedBefore","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include soft-deleted contracts as tombstones (with a populated `deletedAt`). Defaults to false. Pair with `updatedAfter` to incrementally sync deletions.","example":"false"},"required":false,"name":"includeDeleted","in":"query"},{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of contracts","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Contract ID"},"status":{"type":"string","enum":["draft","scheduled","active","ended","terminated","cancelled"],"description":"Contract lifecycle status","example":"active"},"startDate":{"type":"string","description":"Contract start date (YYYY-MM-DD)","example":"2026-04-01"},"endDate":{"type":["string","null"],"description":"Contract end date (YYYY-MM-DD)","example":"2026-06-30"},"durationDays":{"type":["integer","null"],"description":"Derived: number of days between startDate and endDate","example":90},"clientCompany":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Company ID"},"name":{"type":"string","description":"Company name","example":"Bullhorn Inc"}},"required":["id","name"],"description":"The end client company"},"project":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Project ID"},"jobRole":{"type":"string","description":"Job role title","example":"Software Developer"}},"required":["id","jobRole"],"description":"The linked Atlas project"},"contractor":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Person ID"},"firstName":{"type":["string","null"],"description":"First name","example":"Eveling"},"lastName":{"type":["string","null"],"description":"Last name","example":"Garcia"}},"required":["id","firstName","lastName"],"description":"The contractor (person)"},"owner":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"email":{"type":"string","description":"User email","example":"jordan@agency.com"},"name":{"type":"string","description":"User display name","example":"Jordan Smith"}},"required":["id","email","name"],"description":"The user who owns the contract"},"weeklyRevenue":{"type":["string","null"],"description":"Pre-calculated weekly charge revenue (agency base currency)","example":"4000.00"},"weeklyGp":{"type":["string","null"],"description":"Pre-calculated weekly gross profit (agency base currency)","example":"800.00"},"monthlyRevenue":{"type":["string","null"],"description":"Pre-calculated monthly charge revenue (agency base currency)","example":"17333.33"},"monthlyGp":{"type":["string","null"],"description":"Pre-calculated monthly gross profit (agency base currency)","example":"3466.67"},"createdAt":{"type":"string","description":"ISO 8601 creation timestamp"},"updatedAt":{"type":"string","description":"ISO 8601 last-updated timestamp"},"deletedAt":{"type":["string","null"],"description":"ISO 8601 soft-delete timestamp. `null` for live contracts; populated for tombstones (only returned when `includeDeleted=true`)"}},"required":["id","status","startDate","endDate","durationDays","clientCompany","project","contractor","owner","weeklyRevenue","weeklyGp","monthlyRevenue","monthlyGp","createdAt","updatedAt","deletedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":1},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/contracts/{id}":{"get":{"summary":"Get contract details","description":"Use this endpoint to retrieve the full details of a single contract by its ID.\n\nA **contract** represents an ongoing engagement where a contractor works for a client company on a project — the agency invoices the client (the payor side) and pays the contractor or their umbrella/limited company (the payee side). Contracts progress through statuses: `draft`, `scheduled`, `active`, `ended`, `terminated`, and `cancelled`.\n\nEach contract carries its **rates** (charge/pay rates with a single base rate), **contacts** (people linked to the contract such as line managers or timesheet approvers), **attributions** (how the contract's revenue is credited to agency users, each with an optional earner role), and **allowances** (billable expenses such as travel or accommodation).\n\nAll monetary amounts are returned as decimal strings to preserve precision, denominated in the agency base currency unless a currency field says otherwise. `durationDays`, `revenue`, and `grossMarginPercent` are derived from the stored schedule and base rate.\n\n**Contract placement financials:**\nThis endpoint is the source of the fields a contract engagement needs that `GET /api/v1/placements` does not carry: the engagement end date is `endDate`, and the bill/charge rate and pay rate are `chargeRate` and `payRate` on the entry in `rates[]` where `isBase` is `true` (exactly one rate per contract is the base rate). `unit` on that rate says what the amounts are per (`hourly`, `daily`, `weekly`, `monthly`, `project`).\n\n**What you need to provide:**\n- The contract's ID in the URL path\n\n**What you get back:**\nThe contract record with its rates, contacts, attributions, and allowances.","tags":["Contracts"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Contract ID","example":"550e8400-e29b-41d4-a716-446655440000"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Contract details","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Contract ID"},"status":{"type":"string","enum":["draft","scheduled","active","ended","terminated","cancelled"],"description":"Contract lifecycle status","example":"active"},"createdAt":{"type":"string","description":"ISO 8601 creation timestamp"},"updatedAt":{"type":"string","description":"ISO 8601 last-updated timestamp"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"email":{"type":"string","description":"User email","example":"jordan@agency.com"},"name":{"type":"string","description":"User display name","example":"Jordan Smith"}},"required":["id","email","name"],"description":"The user who owns the contract"},"createdBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"email":{"type":"string","description":"User email","example":"jordan@agency.com"},"name":{"type":"string","description":"User display name","example":"Jordan Smith"}},"required":["id","email","name"],"description":"The user who created the contract"},"approvedAt":{"type":["string","null"],"description":"ISO 8601 — when the contract was approved"},"approvedBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"email":{"type":"string","description":"User email","example":"jordan@agency.com"},"name":{"type":"string","description":"User display name","example":"Jordan Smith"}},"required":["id","email","name"],"description":"The user who approved the contract"},"clientCompany":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company ID"},"name":{"type":"string","description":"Company name","example":"Bullhorn Inc"}},"required":["id","name"],"description":"The end client company"},"companyLocation":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company location ID"},"name":{"type":"string","description":"Location name","example":"London HQ"}},"required":["id","name"],"description":"The client office/location the contract is attached to"},"project":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Project ID"},"jobRole":{"type":"string","description":"Job role title","example":"Software Developer"}},"required":["id","jobRole"],"description":"The linked Atlas project"},"contractor":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Person ID"},"firstName":{"type":["string","null"],"description":"First name","example":"Eveling"},"lastName":{"type":["string","null"],"description":"Last name","example":"Garcia"},"email":{"type":["string","null"],"description":"Email address — the person's favourite (or most recently updated) active email identity, falling back to their direct email field","example":"eveling@example.com"}},"required":["id","firstName","lastName","email"],"description":"The contractor (person)"},"contractTypes":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Custom attribute ID"},"name":{"type":"string","description":"Custom attribute name","example":"Employer of Record"}},"required":["id","name"],"description":"Contract custom attribute reference"},"description":"Contract type custom attributes (e.g. \"Employer of Record\") — a contract can have several"},"assignment":{"type":["string","null"],"description":"Free text assignment description","example":"ABC Phase 1"},"purchaseOrderNumber":{"type":["string","null"],"description":"PO number","example":"PO-2026-0042"},"startDate":{"type":"string","description":"Contract start date (YYYY-MM-DD)","example":"2026-04-01"},"endDate":{"type":["string","null"],"description":"Contract end date (YYYY-MM-DD)","example":"2026-06-30"},"daysPerWeek":{"type":["integer","null"],"description":"Working days per week","example":5},"hoursPerDay":{"type":["number","null"],"description":"Working hours per day","example":8},"fixedDaysOverride":{"type":"boolean","description":"Whether fixed working days are set"},"fixedDays":{"type":["array","null"],"items":{"type":"string"},"description":"Array of day names","example":["Monday","Tuesday","Wednesday"]},"durationDays":{"type":["integer","null"],"description":"Derived: number of days between startDate and endDate","example":90},"parentContractId":{"type":["string","null"],"format":"uuid","description":"ID of the parent contract (if this is an extension)"},"extendedBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"email":{"type":"string","description":"User email","example":"jordan@agency.com"},"name":{"type":"string","description":"User display name","example":"Jordan Smith"}},"required":["id","email","name"],"description":"User who extended the contract"},"extendedAt":{"type":["string","null"],"description":"ISO 8601 — when the extension was created"},"payor":{"type":["string","null"],"enum":["project_company","third_party"],"description":"Who pays the agency","example":"project_company"},"payorCompany":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company ID"},"name":{"type":"string","description":"Company name","example":"Bullhorn Inc"}},"required":["id","name"],"description":"The company paying the agency"},"payorInvoiceFrequency":{"type":["string","null"],"enum":["daily","weekly","biweekly","monthly","quarterly"],"description":"Client invoice frequency","example":"monthly"},"payorPaymentTermsDays":{"type":["integer","null"],"description":"Client payment terms in days"},"payorNoticePeriodDays":{"type":["integer","null"],"description":"Client notice period in days"},"payee":{"type":["string","null"],"enum":["umbrella","contractor_company","payroll","none"],"description":"Who the agency pays","example":"contractor_company"},"payeeCompany":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Company ID"},"name":{"type":"string","description":"Company name","example":"Bullhorn Inc"}},"required":["id","name"],"description":"Umbrella company (only if payee = umbrella)"},"payeeContractorCompany":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"PersonContractor ID"},"entityName":{"type":"string","description":"Contractor company legal name","example":"Garcia Consulting Ltd"},"registrationNumber":{"type":["string","null"],"description":"Company registration number","example":"12345678"}},"required":["id","entityName","registrationNumber"],"description":"Contractor's own company (only if payee = contractor_company)"},"payeePayInvoiceFrequency":{"type":["string","null"],"enum":["daily","weekly","biweekly","monthly","quarterly"],"description":"Contractor pay invoice frequency","example":"monthly"},"payeePaymentTermsDays":{"type":["integer","null"],"description":"Contractor payment terms in days"},"payeeNoticePeriodDays":{"type":["integer","null"],"description":"Contractor notice period in days"},"terminationDate":{"type":["string","null"],"description":"ISO 8601 — when termination occurred"},"terminationInitiatedBy":{"type":["string","null"],"enum":["agency","client","contractor"],"description":"Who initiated the termination"},"terminationActionedBy":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"User ID"},"email":{"type":"string","description":"User email","example":"jordan@agency.com"},"name":{"type":"string","description":"User display name","example":"Jordan Smith"}},"required":["id","email","name"],"description":"Internal user who actioned the termination"},"terminationReason":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Custom attribute ID"},"name":{"type":"string","description":"Custom attribute name","example":"Employer of Record"}},"required":["id","name"],"description":"Termination reason custom attribute"},"terminationReasonNotes":{"type":["string","null"],"description":"Free text termination notes"},"weeklyRevenue":{"type":["string","null"],"description":"Pre-calculated weekly charge revenue (agency base currency)","example":"4000.00"},"weeklyGp":{"type":["string","null"],"description":"Pre-calculated weekly gross profit (agency base currency)","example":"800.00"},"monthlyRevenue":{"type":["string","null"],"description":"Pre-calculated monthly charge revenue (agency base currency)","example":"17333.33"},"monthlyGp":{"type":["string","null"],"description":"Pre-calculated monthly gross profit (agency base currency)","example":"3466.67"},"revenue":{"type":["string","null"],"description":"Derived: total contract revenue based on the base rate, schedule, and duration — in the agency base currency","example":"52000.00"},"grossMarginPercent":{"type":["string","null"],"description":"Derived: gross margin percentage from base charge vs base pay rate","example":"20.00"},"rates":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Rate ID"},"rateType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Custom attribute ID"},"name":{"type":"string","description":"Custom attribute name","example":"Employer of Record"}},"required":["id","name"],"description":"Rate type custom attribute (e.g. \"Base\", \"Overtime\", \"Weekend\")"},"isBase":{"type":"boolean","description":"Whether this is the base rate (only one per contract)"},"unit":{"type":"string","enum":["hourly","daily","weekly","monthly","project"],"description":"Rate unit","example":"daily"},"chargeRate":{"type":["string","null"],"description":"Charge rate to client","example":"800.00"},"chargeCurrency":{"type":"string","description":"Charge currency code","example":"GBP"},"payRate":{"type":["string","null"],"description":"Pay rate to contractor","example":"640.00"},"payCurrency":{"type":"string","description":"Pay currency code","example":"GBP"},"baseCurrency":{"type":["string","null"],"description":"Agency base currency","example":"GBP"},"baseChargeRate":{"type":["string","null"],"description":"Charge rate converted to agency base currency","example":"800.00"},"basePayRate":{"type":["string","null"],"description":"Pay rate converted to agency base currency","example":"640.00"},"marginPercent":{"type":["string","null"],"description":"Margin percentage","example":"20.00"},"payBundle":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Pay bundle ID"},"name":{"type":"string","description":"Pay bundle name"}},"required":["id","name"],"description":"Pay bundle template, if assigned"}},"required":["id","rateType","isBase","unit","chargeRate","chargeCurrency","payRate","payCurrency","baseCurrency","baseChargeRate","basePayRate","marginPercent","payBundle"]},"description":"Contract rates — at least one (the base rate)"},"contacts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Contact record ID"},"person":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Person ID"},"firstName":{"type":["string","null"],"description":"First name","example":"Eveling"},"lastName":{"type":["string","null"],"description":"Last name","example":"Garcia"},"email":{"type":["string","null"],"description":"Email address — the person's favourite (or most recently updated) active email identity, falling back to their direct email field","example":"eveling@example.com"}},"required":["id","firstName","lastName","email"],"description":"The linked person"},"email":{"type":["string","null"],"description":"Contact email (if no person linked)"},"role":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Custom attribute ID"},"name":{"type":"string","description":"Custom attribute name","example":"Employer of Record"}},"required":["id","name"],"description":"Contact role custom attribute (e.g. \"Line Manager\", \"Timesheet Approver\")"}},"required":["id","person","email","role"]},"description":"Contract contacts — may be empty"},"attributions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribution ID"},"feeEarner":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Fee earner (user) ID"},"name":{"type":["string","null"],"description":"Fee earner display name","example":"Jane Smith"},"email":{"type":["string","null"],"description":"Fee earner email address","example":"jane@agency.com"}},"required":["id","name","email"],"description":"User the revenue is attributed to"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Fee type ID"},"name":{"type":"string","description":"Fee type name (earner role)","example":"Recruiter"}},"required":["id","name"],"description":"Earner role on this contract; null when none is assigned"},"share":{"type":["string","null"],"description":"Percentage of the revenue attributed to this user","example":"100.00"},"notes":{"type":["string","null"],"description":"Notes on this attribution"}},"required":["id","feeEarner","feeType","share","notes"]},"description":"Fee attributions — may be empty"},"allowances":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Allowance ID"},"allowanceType":{"type":"string","enum":["travel","food","accommodation","materials","equipment","mileage","other"],"description":"Type of expense/allowance","example":"travel"},"amount":{"type":["string","null"],"description":"Allowance amount as decimal string","example":"50.00"},"currency":{"type":"string","description":"Allowance currency code","example":"GBP"},"unit":{"type":"string","enum":["per_day","per_week","per_month","fixed_total","per_mile","per_km"],"description":"How the allowance is applied","example":"per_day"},"capAmount":{"type":["string","null"],"description":"Cap amount as decimal string","example":"500.00"},"capUnit":{"type":["string","null"],"enum":["per_day","per_week","per_month","fixed_total","per_mile","per_km"],"description":"Unit the cap applies to"},"notes":{"type":["string","null"],"description":"Notes on this allowance"}},"required":["id","allowanceType","amount","currency","unit","capAmount","capUnit","notes"]},"description":"Expenses/allowances — may be empty"}},"required":["id","status","createdAt","updatedAt","owner","createdBy","approvedAt","approvedBy","clientCompany","companyLocation","project","contractor","contractTypes","assignment","purchaseOrderNumber","startDate","endDate","daysPerWeek","hoursPerDay","fixedDaysOverride","fixedDays","durationDays","parentContractId","extendedBy","extendedAt","payor","payorCompany","payorInvoiceFrequency","payorPaymentTermsDays","payorNoticePeriodDays","payee","payeeCompany","payeeContractorCompany","payeePayInvoiceFrequency","payeePaymentTermsDays","payeeNoticePeriodDays","terminationDate","terminationInitiatedBy","terminationActionedBy","terminationReason","terminationReasonNotes","weeklyRevenue","weeklyGp","monthlyRevenue","monthlyGp","revenue","grossMarginPercent","rates","contacts","attributions","allowances"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Contract not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/{id}/custom-attributes/{attributeId}/values":{"post":{"summary":"Append a custom attribute option to a person","description":"Adds a single option selection to an `options`-type custom attribute on a person record **without replacing the existing selections** — unlike `PATCH /people/{id}`, which replaces all values of each attribute it receives.\n\nAppending an option that is already selected is idempotent: the existing selection is returned with `200` and no duplicate is created. For single-select attributes (`multipleValues: false`) the append is rejected with `409` when another value is already set.\n\nReturns `404` if the attribute, option, or person does not exist for this agency, and `422` if the attribute is not of type `options`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Record identifier (person or company ID)","example":"4f5e6d7c-8b9a-4c3d-9e2f-1a2b3c4d5e6f"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"attributeId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Option to select on the record. Must belong to the attribute in the URL.","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"}},"required":["optionId"],"additionalProperties":false}}}},"responses":{"200":{"description":"Option was already selected (idempotent no-op)","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Identifier of the stored value row"},"customAttributeId":{"type":"string","format":"uuid","description":"Attribute the value belongs to"},"optionId":{"type":["string","null"],"format":"uuid","description":"The selected option"}},"required":["id","customAttributeId","optionId"]}},"required":["status","data"]}}}},"201":{"description":"Option appended","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Identifier of the stored value row"},"customAttributeId":{"type":"string","format":"uuid","description":"Attribute the value belongs to"},"optionId":{"type":["string","null"],"format":"uuid","description":"The selected option"}},"required":["id","customAttributeId","optionId"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/{id}/custom-attributes/{attributeId}/values/{optionId}":{"delete":{"summary":"Remove a custom attribute option from a person","description":"Removes a single option selection from an `options`-type custom attribute on a person record, leaving all other selections untouched.\n\nReturns `404` if the attribute, option, or person does not exist for this agency, or if the option is not currently selected on the record.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Record identifier (person or company ID)","example":"4f5e6d7c-8b9a-4c3d-9e2f-1a2b3c4d5e6f"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"attributeId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute option ID","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"},"required":true,"name":"optionId","in":"path"}],"responses":{"200":{"description":"Option removed","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Identifier of the deleted value row"},"optionId":{"type":"string","format":"uuid","description":"The option that was deselected"}},"required":["id","optionId"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/companies/{id}/custom-attributes/{attributeId}/values":{"post":{"summary":"Append a custom attribute option to a company","description":"Adds a single option selection to an `options`-type custom attribute on a company record **without replacing the existing selections** — unlike `PATCH /companies/{id}`, which replaces all values of each attribute it receives.\n\nAppending an option that is already selected is idempotent: the existing selection is returned with `200` and no duplicate is created. For single-select attributes (`multipleValues: false`) the append is rejected with `409` when another value is already set.\n\nReturns `404` if the attribute, option, or company does not exist for this agency, and `422` if the attribute is not of type `options`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Record identifier (person or company ID)","example":"4f5e6d7c-8b9a-4c3d-9e2f-1a2b3c4d5e6f"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"attributeId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"optionId":{"type":"string","format":"uuid","description":"Option to select on the record. Must belong to the attribute in the URL.","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"}},"required":["optionId"],"additionalProperties":false}}}},"responses":{"200":{"description":"Option was already selected (idempotent no-op)","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Identifier of the stored value row"},"customAttributeId":{"type":"string","format":"uuid","description":"Attribute the value belongs to"},"optionId":{"type":["string","null"],"format":"uuid","description":"The selected option"}},"required":["id","customAttributeId","optionId"]}},"required":["status","data"]}}}},"201":{"description":"Option appended","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Identifier of the stored value row"},"customAttributeId":{"type":"string","format":"uuid","description":"Attribute the value belongs to"},"optionId":{"type":["string","null"],"format":"uuid","description":"The selected option"}},"required":["id","customAttributeId","optionId"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/companies/{id}/custom-attributes/{attributeId}/values/{optionId}":{"delete":{"summary":"Remove a custom attribute option from a company","description":"Removes a single option selection from an `options`-type custom attribute on a company record, leaving all other selections untouched.\n\nReturns `404` if the attribute, option, or company does not exist for this agency, or if the option is not currently selected on the record.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Record identifier (person or company ID)","example":"4f5e6d7c-8b9a-4c3d-9e2f-1a2b3c4d5e6f"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"attributeId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute option ID","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"},"required":true,"name":"optionId","in":"path"}],"responses":{"200":{"description":"Option removed","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Identifier of the deleted value row"},"optionId":{"type":"string","format":"uuid","description":"The option that was deselected"}},"required":["id","optionId"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/custom-attributes":{"get":{"summary":"List person custom attribute definitions","description":"Returns the custom attribute **definitions** (schema) configured for person records on this agency, including selectable options for dropdown-type attributes.\n\nUse this to discover which custom fields exist and what values are valid before reading or writing attribute data on person records.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of person custom attribute definitions","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":4},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Create a person custom attribute definition","description":"Creates a new agency-wide custom attribute **definition** for person records.\n\nFor `options`-type attributes, provide the selectable choices via `options` (in display order) and optionally set `multipleValues` to allow multi-select. The attribute name must be unique per entity type within the agency (exact match, case-sensitive — same as the Atlas app) — a duplicate name returns `409`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":255,"description":"Display name. Must be unique per entity type within the agency (exact match, case-sensitive). Leading/trailing whitespace is trimmed.","example":"Seniority Level"},"type":{"type":"string","enum":["options","text_block","text_line","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","default":false,"description":"When true and `type` is `options`, multiple options can be selected. Only allowed for `options` type. Defaults to false.","example":false},"options":{"type":"array","items":{"type":"string","minLength":1,"maxLength":255},"maxItems":100,"description":"Selectable choices, in display order. Required (at least one value) when `type` is `options`; not allowed otherwise. Values must be unique (case-insensitive).","example":["Junior","Mid","Senior"]}},"required":["name","type"],"additionalProperties":false}}}},"responses":{"201":{"description":"Created person custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/custom-attributes/{attributeId}/options/{optionId}":{"delete":{"summary":"Delete an option from a person custom attribute","description":"Removes one selectable option from an `options`-type custom attribute definition for person records.\n\n**In-use options cascade**: if the option is currently selected on any records, those stored values are deleted in the same transaction — across **all** record types (people, companies, projects, meetings, candidates, placements), not just person records. This matches the behaviour of deleting an option in the Atlas app. The attribute definition itself and its other options are not affected.\n\nReturns `404` if the attribute does not exist for this agency under this entity type, or if the option does not belong to the attribute.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"attributeId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute option ID","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"},"required":true,"name":"optionId","in":"path"}],"responses":{"200":{"description":"Option deleted","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"ID of the deleted option"}},"required":["id"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/people/custom-attributes/{id}/options":{"post":{"summary":"Add options to a person custom attribute","description":"Adds one or more selectable options to an existing `options`-type custom attribute **definition** for person records.\n\nThe body accepts either a single option object or an array of them (max 100). Each option takes a `value` and an optional 1-based `position`; when `position` is omitted the option is appended to the end. Inserting at an occupied position shifts existing options down. Option values must be unique within the attribute (case-insensitive) — a duplicate returns `409`. Adding options to a non-`options` attribute returns `422`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute definition identifier"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},"maxItems":100}]}}}},"responses":{"201":{"description":"Options added to the person custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"],"description":"The full attribute definition including all options (existing and newly added), sorted by position"},"addedOptionIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Identifiers of the newly created options, in the order they appeared in the request"}},"required":["status","data","addedOptionIds"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/companies/custom-attributes":{"get":{"summary":"List company custom attribute definitions","description":"Returns the custom attribute **definitions** (schema) configured for company records on this agency, including selectable options for dropdown-type attributes.\n\nUse this to discover which custom fields exist and what values are valid before reading or writing attribute data on company records.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of company custom attribute definitions","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":4},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Create a company custom attribute definition","description":"Creates a new agency-wide custom attribute **definition** for company records.\n\nFor `options`-type attributes, provide the selectable choices via `options` (in display order) and optionally set `multipleValues` to allow multi-select. The attribute name must be unique per entity type within the agency (exact match, case-sensitive — same as the Atlas app) — a duplicate name returns `409`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":255,"description":"Display name. Must be unique per entity type within the agency (exact match, case-sensitive). Leading/trailing whitespace is trimmed.","example":"Seniority Level"},"type":{"type":"string","enum":["options","text_block","text_line","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","default":false,"description":"When true and `type` is `options`, multiple options can be selected. Only allowed for `options` type. Defaults to false.","example":false},"options":{"type":"array","items":{"type":"string","minLength":1,"maxLength":255},"maxItems":100,"description":"Selectable choices, in display order. Required (at least one value) when `type` is `options`; not allowed otherwise. Values must be unique (case-insensitive).","example":["Junior","Mid","Senior"]}},"required":["name","type"],"additionalProperties":false}}}},"responses":{"201":{"description":"Created company custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/companies/custom-attributes/{attributeId}/options/{optionId}":{"delete":{"summary":"Delete an option from a company custom attribute","description":"Removes one selectable option from an `options`-type custom attribute definition for company records.\n\n**In-use options cascade**: if the option is currently selected on any records, those stored values are deleted in the same transaction — across **all** record types (people, companies, projects, meetings, candidates, placements), not just company records. This matches the behaviour of deleting an option in the Atlas app. The attribute definition itself and its other options are not affected.\n\nReturns `404` if the attribute does not exist for this agency under this entity type, or if the option does not belong to the attribute.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"attributeId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute option ID","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"},"required":true,"name":"optionId","in":"path"}],"responses":{"200":{"description":"Option deleted","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"ID of the deleted option"}},"required":["id"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/companies/custom-attributes/{id}/options":{"post":{"summary":"Add options to a company custom attribute","description":"Adds one or more selectable options to an existing `options`-type custom attribute **definition** for company records.\n\nThe body accepts either a single option object or an array of them (max 100). Each option takes a `value` and an optional 1-based `position`; when `position` is omitted the option is appended to the end. Inserting at an occupied position shifts existing options down. Option values must be unique within the attribute (case-insensitive) — a duplicate returns `409`. Adding options to a non-`options` attribute returns `422`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute definition identifier"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},"maxItems":100}]}}}},"responses":{"201":{"description":"Options added to the company custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"],"description":"The full attribute definition including all options (existing and newly added), sorted by position"},"addedOptionIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Identifiers of the newly created options, in the order they appeared in the request"}},"required":["status","data","addedOptionIds"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/custom-attributes":{"get":{"summary":"List project custom attribute definitions","description":"Returns the custom attribute **definitions** (schema) configured for project records on this agency, including selectable options for dropdown-type attributes.\n\nUse this to discover which custom fields exist and what values are valid before reading or writing attribute data on project records.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of project custom attribute definitions","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":4},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Create a project custom attribute definition","description":"Creates a new agency-wide custom attribute **definition** for project records.\n\nFor `options`-type attributes, provide the selectable choices via `options` (in display order) and optionally set `multipleValues` to allow multi-select. The attribute name must be unique per entity type within the agency (exact match, case-sensitive — same as the Atlas app) — a duplicate name returns `409`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":255,"description":"Display name. Must be unique per entity type within the agency (exact match, case-sensitive). Leading/trailing whitespace is trimmed.","example":"Seniority Level"},"type":{"type":"string","enum":["options","text_block","text_line","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","default":false,"description":"When true and `type` is `options`, multiple options can be selected. Only allowed for `options` type. Defaults to false.","example":false},"options":{"type":"array","items":{"type":"string","minLength":1,"maxLength":255},"maxItems":100,"description":"Selectable choices, in display order. Required (at least one value) when `type` is `options`; not allowed otherwise. Values must be unique (case-insensitive).","example":["Junior","Mid","Senior"]}},"required":["name","type"],"additionalProperties":false}}}},"responses":{"201":{"description":"Created project custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/custom-attributes/{attributeId}/options/{optionId}":{"delete":{"summary":"Delete an option from a project custom attribute","description":"Removes one selectable option from an `options`-type custom attribute definition for project records.\n\n**In-use options cascade**: if the option is currently selected on any records, those stored values are deleted in the same transaction — across **all** record types (people, companies, projects, meetings, candidates, placements), not just project records. This matches the behaviour of deleting an option in the Atlas app. The attribute definition itself and its other options are not affected.\n\nReturns `404` if the attribute does not exist for this agency under this entity type, or if the option does not belong to the attribute.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"attributeId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute option ID","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"},"required":true,"name":"optionId","in":"path"}],"responses":{"200":{"description":"Option deleted","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"ID of the deleted option"}},"required":["id"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/projects/custom-attributes/{id}/options":{"post":{"summary":"Add options to a project custom attribute","description":"Adds one or more selectable options to an existing `options`-type custom attribute **definition** for project records.\n\nThe body accepts either a single option object or an array of them (max 100). Each option takes a `value` and an optional 1-based `position`; when `position` is omitted the option is appended to the end. Inserting at an occupied position shifts existing options down. Option values must be unique within the attribute (case-insensitive) — a duplicate returns `409`. Adding options to a non-`options` attribute returns `422`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute definition identifier"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},"maxItems":100}]}}}},"responses":{"201":{"description":"Options added to the project custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"],"description":"The full attribute definition including all options (existing and newly added), sorted by position"},"addedOptionIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Identifiers of the newly created options, in the order they appeared in the request"}},"required":["status","data","addedOptionIds"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/meetings/custom-attributes":{"get":{"summary":"List meeting custom attribute definitions","description":"Returns the custom attribute **definitions** (schema) configured for meeting records on this agency, including selectable options for dropdown-type attributes.\n\nUse this to discover which custom fields exist and what values are valid before reading or writing attribute data on meeting records.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of meeting custom attribute definitions","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":4},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Create a meeting custom attribute definition","description":"Creates a new agency-wide custom attribute **definition** for meeting records.\n\nFor `options`-type attributes, provide the selectable choices via `options` (in display order) and optionally set `multipleValues` to allow multi-select. The attribute name must be unique per entity type within the agency (exact match, case-sensitive — same as the Atlas app) — a duplicate name returns `409`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":255,"description":"Display name. Must be unique per entity type within the agency (exact match, case-sensitive). Leading/trailing whitespace is trimmed.","example":"Seniority Level"},"type":{"type":"string","enum":["options","text_block","text_line","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","default":false,"description":"When true and `type` is `options`, multiple options can be selected. Only allowed for `options` type. Defaults to false.","example":false},"options":{"type":"array","items":{"type":"string","minLength":1,"maxLength":255},"maxItems":100,"description":"Selectable choices, in display order. Required (at least one value) when `type` is `options`; not allowed otherwise. Values must be unique (case-insensitive).","example":["Junior","Mid","Senior"]}},"required":["name","type"],"additionalProperties":false}}}},"responses":{"201":{"description":"Created meeting custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/meetings/custom-attributes/{attributeId}/options/{optionId}":{"delete":{"summary":"Delete an option from a meeting custom attribute","description":"Removes one selectable option from an `options`-type custom attribute definition for meeting records.\n\n**In-use options cascade**: if the option is currently selected on any records, those stored values are deleted in the same transaction — across **all** record types (people, companies, projects, meetings, candidates, placements), not just meeting records. This matches the behaviour of deleting an option in the Atlas app. The attribute definition itself and its other options are not affected.\n\nReturns `404` if the attribute does not exist for this agency under this entity type, or if the option does not belong to the attribute.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"attributeId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute option ID","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"},"required":true,"name":"optionId","in":"path"}],"responses":{"200":{"description":"Option deleted","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"ID of the deleted option"}},"required":["id"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/meetings/custom-attributes/{id}/options":{"post":{"summary":"Add options to a meeting custom attribute","description":"Adds one or more selectable options to an existing `options`-type custom attribute **definition** for meeting records.\n\nThe body accepts either a single option object or an array of them (max 100). Each option takes a `value` and an optional 1-based `position`; when `position` is omitted the option is appended to the end. Inserting at an occupied position shifts existing options down. Option values must be unique within the attribute (case-insensitive) — a duplicate returns `409`. Adding options to a non-`options` attribute returns `422`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute definition identifier"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},"maxItems":100}]}}}},"responses":{"201":{"description":"Options added to the meeting custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"],"description":"The full attribute definition including all options (existing and newly added), sorted by position"},"addedOptionIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Identifiers of the newly created options, in the order they appeared in the request"}},"required":["status","data","addedOptionIds"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/placements/custom-attributes":{"get":{"summary":"List placement custom attribute definitions","description":"Returns the custom attribute **definitions** (schema) configured for placement records on this agency, including selectable options for dropdown-type attributes.\n\nUse this to discover which custom fields exist and what values are valid before reading or writing attribute data on placement records.\n\nEach placement attribute applies to contract placements, permanent placements or both — see `record_type`. This endpoint returns them all; a placement only accepts values for the attributes matching its own kind.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"default":1,"description":"Page number (1-indexed)","example":1},"required":false,"name":"page","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Items per page (max 100)","example":25},"required":false,"name":"pageSize","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Alias for pageSize","deprecated":true},"required":false,"name":"perPage","in":"query"}],"responses":{"200":{"description":"Paginated list of placement custom attribute definitions","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"pagination":{"type":"object","properties":{"page":{"type":"integer","description":"Current page number","example":1},"pageSize":{"type":"integer","description":"Items per page","example":25},"total":{"type":"integer","description":"Total matching items","example":4},"totalPages":{"type":"integer","description":"Total number of pages","example":1}},"required":["page","pageSize","total","totalPages"]}},"required":["status","data","pagination"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}},"post":{"summary":"Create a placement custom attribute definition","description":"Creates a new agency-wide custom attribute **definition** for placement records.\n\nFor `options`-type attributes, provide the selectable choices via `options` (in display order) and optionally set `multipleValues` to allow multi-select. The attribute name must be unique per entity type within the agency (exact match, case-sensitive — same as the Atlas app) — a duplicate name returns `409`.\n\nEach placement attribute applies to contract placements, permanent placements or both — see `record_type`. This endpoint returns them all; a placement only accepts values for the attributes matching its own kind.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":255,"description":"Display name. Must be unique per entity type within the agency (exact match, case-sensitive). Leading/trailing whitespace is trimmed.","example":"Seniority Level"},"type":{"type":"string","enum":["options","text_block","text_line","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","default":false,"description":"When true and `type` is `options`, multiple options can be selected. Only allowed for `options` type. Defaults to false.","example":false},"options":{"type":"array","items":{"type":"string","minLength":1,"maxLength":255},"maxItems":100,"description":"Selectable choices, in display order. Required (at least one value) when `type` is `options`; not allowed otherwise. Values must be unique (case-insensitive).","example":["Junior","Mid","Senior"]},"recordType":{"type":"string","enum":["both","contract","permanent"],"default":"both","description":"Which placement kinds the attribute applies to: `contract`, `permanent`, or `both`. Defaults to `both`.","example":"contract"}},"required":["name","type"],"additionalProperties":false}}}},"responses":{"201":{"description":"Created placement custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/placements/custom-attributes/{attributeId}/options/{optionId}":{"delete":{"summary":"Delete an option from a placement custom attribute","description":"Removes one selectable option from an `options`-type custom attribute definition for placement records.\n\n**In-use options cascade**: if the option is currently selected on any records, those stored values are deleted in the same transaction — across **all** record types (people, companies, projects, meetings, candidates, placements), not just placement records. This matches the behaviour of deleting an option in the Atlas app. The attribute definition itself and its other options are not affected.\n\nReturns `404` if the attribute does not exist for this agency under this entity type, or if the option does not belong to the attribute.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute ID","example":"123e4567-e89b-12d3-a456-426614174000"},"required":true,"name":"attributeId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Custom attribute option ID","example":"7c9e6679-7425-40de-944b-e07fc1f90ae7"},"required":true,"name":"optionId","in":"path"}],"responses":{"200":{"description":"Option deleted","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"ID of the deleted option"}},"required":["id"]}},"required":["status","data"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}},"/api/v1/placements/custom-attributes/{id}/options":{"post":{"summary":"Add options to a placement custom attribute","description":"Adds one or more selectable options to an existing `options`-type custom attribute **definition** for placement records.\n\nThe body accepts either a single option object or an array of them (max 100). Each option takes a `value` and an optional 1-based `position`; when `position` is omitted the option is appended to the end. Inserting at an occupied position shifts existing options down. Option values must be unique within the attribute (case-insensitive) — a duplicate returns `409`. Adding options to a non-`options` attribute returns `422`.","tags":["Custom Attributes"],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Custom attribute definition identifier"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","minLength":1,"maxLength":255,"description":"Display label of the option. Must be unique within the attribute (case-insensitive). Leading/trailing whitespace is trimmed.","example":"Principal"},"position":{"type":"integer","minimum":1,"description":"1-based position to insert the option at; existing options at or after this position shift down. Appended to the end when omitted. Must not exceed the current option count + 1.","example":4}},"required":["value"],"additionalProperties":false},"maxItems":100}]}}}},"responses":{"201":{"description":"Options added to the placement custom attribute definition","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Attribute identifier. Use this when reading/writing values."},"name":{"type":"string","description":"Display name","example":"Seniority Level"},"description":{"type":["string","null"],"description":"Optional description of the attribute's purpose","example":"Candidate's seniority level"},"type":{"type":"string","enum":["options","text_block","text_line","number_input","integer","date"],"description":"Data type of the attribute","example":"options"},"multipleValues":{"type":"boolean","description":"When true and `type` is `options`, multiple options can be selected","example":true},"recordType":{"type":"string","enum":["both","contract","permanent"],"description":"Placement attributes only — which placement kinds the attribute applies to","example":"contract"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Option identifier. Use this when setting a value."},"value":{"type":"string","description":"Display label","example":"Senior"},"position":{"type":"integer","description":"Sort order (ascending)","example":0}},"required":["id","value","position"]},"description":"Selectable choices. Populated when type is `options`, empty array otherwise."},"createdAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was created"},"updatedAt":{"type":["string","null"],"format":"date-time","description":"ISO 8601 timestamp when the attribute was last updated"}},"required":["id","name","description","type","multipleValues","options","createdAt","updatedAt"],"description":"The full attribute definition including all options (existing and newly added), sorted by position"},"addedOptionIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Identifiers of the newly created options, in the order they appeared in the request"}},"required":["status","data","addedOptionIds"]}}}},"401":{"description":"Unauthorized - missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Unauthorized"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Not found"}}}},"409":{"description":"Conflict - the request cannot be fulfilled because of a conflict with the current state of the resource","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"websiteUrl matches company <id-a> but linkedinUrl matches company <id-b>. Submit only one identity, or reconcile the companies first."}}}},"422":{"description":"Validation error - the request body or query parameters failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"errors":{"type":"object","properties":{"formErrors":{"type":"array","items":{"type":"string"},"description":"Top-level validation errors"},"fieldErrors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Per-field validation errors keyed by field name"}},"required":["formErrors","fieldErrors"]}},"required":["status","errors"]},"example":{"status":"error","errors":{"formErrors":[],"fieldErrors":{"email":["Invalid email"]}}}}}},"429":{"description":"Too many requests - the caller has exceeded the per-agency rate limit for the tier this endpoint counts against (default per minute: 1200 read / 400 write / 60 upload). Inspect the `RateLimit-*` headers — returned on every response, not only on 429s — and back off until the window resets. See the \"Rate limits\" section of the introduction for details.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimitReset"},"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"},"tier":{"type":"string","enum":["read","write","upload"],"description":"The rate-limit tier whose quota was exceeded. GET endpoints count against `read`, POST/PUT/PATCH/DELETE against `write`, and multipart file uploads against `upload` — each tier has an independent counter."},"retryAfterSec":{"type":"integer","description":"Number of seconds after which the rate-limit window resets and requests will be accepted again. Prefer this (or the `RateLimit-Reset` header) over the `Retry-After` header, which is not guaranteed to be present."}},"required":["status","error","tier","retryAfterSec"]},"example":{"status":"error","error":"Rate limit exceeded","tier":"read","retryAfterSec":60}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string","description":"Human-readable error message"}},"required":["status","error"]},"example":{"status":"error","error":"Internal Server error"}}}}}}}},"webhooks":{"person.created":{"post":{"summary":"Person Created","description":"Fired when a new person record is created in Atlas.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["person.created"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"middleName":{"type":["string","null"]},"isContact":{"type":"boolean"},"gender":{"type":["string","null"]},"source":{"type":["object","null"],"properties":{"system":{"type":["string","null"]},"externalId":{"type":["string","null"]}}},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"value":{"type":"string"},"isPersonal":{"type":"boolean"},"isPrimary":{"type":"boolean"}},"required":["type","value","isPersonal","isPrimary"]}},"headline":{"type":["object","null"],"properties":{"role":{"type":["string","null"]},"company":{"type":["string","null"]},"companyId":{"type":["string","null"]},"roleStartedAt":{}}},"address":{"type":["object","null"],"properties":{"raw":{"type":["string","null"]},"streetAddress":{"type":["string","null"]},"addressLine2":{"type":["string","null"]},"city":{"type":["string","null"]},"region":{"type":["string","null"]},"postalCode":{"type":["string","null"]},"country":{"type":["string","null"]},"metro":{"type":["string","null"]},"formattedAddress":{"type":["string","null"]},"latitude":{"type":["number","null"]},"longitude":{"type":["number","null"]}}},"experience":{"type":"array","items":{"type":"object","properties":{"companyName":{"type":"string"},"companyLinkedinId":{"type":["string","null"]},"companyDomain":{"type":["string","null"]},"role":{"type":"string"},"description":{"type":["string","null"]},"startDate":{"type":["string","null"]},"endDate":{"type":["string","null"]}},"required":["companyName","role"]}},"education":{"type":"array","items":{"type":"object","properties":{"institutionName":{"type":"string"},"degree":{"type":["string","null"]},"fieldOfStudy":{"type":["string","null"]},"grade":{"type":["string","null"]},"description":{"type":["string","null"]},"startDate":{},"endDate":{}},"required":["institutionName"]}},"compensation":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"taxMethod":{"type":["string","null"]},"currency":{"type":["string","null"]},"relevantDate":{},"basicSalary":{"type":["string","null"]},"bonusSalary":{"type":["string","null"]},"totalSalary":{"type":["string","null"]},"expectedSalaryMin":{"type":["string","null"]},"expectedSalaryMax":{"type":["string","null"]},"expectedBonusSalaryMin":{"type":["string","null"]},"expectedBonusSalaryMax":{"type":["string","null"]}},"required":["type"]}},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string"},"attributeName":{"type":["string","null"]},"attributeType":{"type":["string","null"]},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string"},"optionValue":{"type":["string","null"]}},"required":["optionId"]}]}}},"required":["attributeId","values"]}},"createdAt":{},"updatedAt":{}},"required":["id","isContact","source","identities","headline","address","experience","education","compensation","customAttributes"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"person.updated":{"post":{"summary":"Person Updated","description":"Fired when a person record is updated.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["person.updated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"middleName":{"type":["string","null"]},"isContact":{"type":"boolean"},"gender":{"type":["string","null"]},"source":{"type":["object","null"],"properties":{"system":{"type":["string","null"]},"externalId":{"type":["string","null"]}}},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"value":{"type":"string"},"isPersonal":{"type":"boolean"},"isPrimary":{"type":"boolean"}},"required":["type","value","isPersonal","isPrimary"]}},"headline":{"type":["object","null"],"properties":{"role":{"type":["string","null"]},"company":{"type":["string","null"]},"companyId":{"type":["string","null"]},"roleStartedAt":{}}},"address":{"type":["object","null"],"properties":{"raw":{"type":["string","null"]},"streetAddress":{"type":["string","null"]},"addressLine2":{"type":["string","null"]},"city":{"type":["string","null"]},"region":{"type":["string","null"]},"postalCode":{"type":["string","null"]},"country":{"type":["string","null"]},"metro":{"type":["string","null"]},"formattedAddress":{"type":["string","null"]},"latitude":{"type":["number","null"]},"longitude":{"type":["number","null"]}}},"experience":{"type":"array","items":{"type":"object","properties":{"companyName":{"type":"string"},"companyLinkedinId":{"type":["string","null"]},"companyDomain":{"type":["string","null"]},"role":{"type":"string"},"description":{"type":["string","null"]},"startDate":{"type":["string","null"]},"endDate":{"type":["string","null"]}},"required":["companyName","role"]}},"education":{"type":"array","items":{"type":"object","properties":{"institutionName":{"type":"string"},"degree":{"type":["string","null"]},"fieldOfStudy":{"type":["string","null"]},"grade":{"type":["string","null"]},"description":{"type":["string","null"]},"startDate":{},"endDate":{}},"required":["institutionName"]}},"compensation":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"taxMethod":{"type":["string","null"]},"currency":{"type":["string","null"]},"relevantDate":{},"basicSalary":{"type":["string","null"]},"bonusSalary":{"type":["string","null"]},"totalSalary":{"type":["string","null"]},"expectedSalaryMin":{"type":["string","null"]},"expectedSalaryMax":{"type":["string","null"]},"expectedBonusSalaryMin":{"type":["string","null"]},"expectedBonusSalaryMax":{"type":["string","null"]}},"required":["type"]}},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string"},"attributeName":{"type":["string","null"]},"attributeType":{"type":["string","null"]},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string"},"optionValue":{"type":["string","null"]}},"required":["optionId"]}]}}},"required":["attributeId","values"]}},"createdAt":{},"updatedAt":{}},"required":["id","isContact","source","identities","headline","address","experience","education","compensation","customAttributes"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"person.contactCreated":{"post":{"summary":"Company Contact Created","description":"Fired when a person is linked to a company as a contact.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["person.contactCreated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"companyContact":{"type":"object","properties":{"id":{"type":"string"},"relationship":{"type":"string"},"title":{"type":["string","null"]},"seniority":{"type":["string","null"]},"createdAt":{}},"required":["id","relationship"]},"person":{"type":"object","properties":{"id":{"type":"string"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"value":{"type":"string"},"isPersonal":{"type":"boolean"},"isPrimary":{"type":"boolean"}},"required":["type","value","isPersonal","isPrimary"]}},"headline":{"type":["object","null"],"properties":{"role":{"type":["string","null"]},"company":{"type":["string","null"]}}}},"required":["id","identities","headline"]},"company":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"]}},"required":["id"]},"project":{"type":["object","null"],"properties":{"id":{"type":"string"},"jobRole":{"type":"string"}},"required":["id","jobRole"]}},"required":["companyContact","person","company","project"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"person.contactUpdated":{"post":{"summary":"Company Contact Updated","description":"Fired when a company contact relationship is updated. The `changes` field contains the before/after values for each changed field.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["person.contactUpdated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"companyContact":{"type":"object","properties":{"id":{"type":"string"},"relationship":{"type":"string"},"title":{"type":["string","null"]},"seniority":{"type":["string","null"]},"createdAt":{}},"required":["id","relationship"]},"person":{"type":"object","properties":{"id":{"type":"string"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]}},"required":["id"]},"company":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"]}},"required":["id"]},"changes":{"type":"object","additionalProperties":{"type":"object","properties":{"from":{},"to":{}}}}},"required":["companyContact","person","company","changes"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"person.experienceCreated":{"post":{"summary":"Person Experience Created","description":"Fired when a new work experience entry is added to a person.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["person.experienceCreated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"personId":{"type":"string"},"experienceId":{"type":"string"},"type":{"type":"string","enum":["promotion","moved_company"]},"experience":{"type":"object","properties":{"companyName":{"type":"string"},"companyLinkedinId":{"type":["string","null"]},"companyDomain":{"type":["string","null"]},"role":{"type":"string"},"description":{"type":["string","null"]},"startDate":{"type":["string","null"]},"endDate":{"type":["string","null"]}},"required":["companyName","role"]}},"required":["personId","experienceId","type","experience"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"person.experienceUpdated":{"post":{"summary":"Person Experience Updated","description":"Fired when a work experience entry on a person is updated.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["person.experienceUpdated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"personId":{"type":"string"},"experienceId":{"type":"string"},"experience":{"type":"object","properties":{"companyName":{"type":"string"},"companyLinkedinId":{"type":["string","null"]},"companyDomain":{"type":["string","null"]},"role":{"type":"string"},"description":{"type":["string","null"]},"startDate":{"type":["string","null"]},"endDate":{"type":["string","null"]}},"required":["companyName","role"]},"updatedAt":{"type":"string"}},"required":["personId","experienceId","experience","updatedAt"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"candidate.rejected":{"post":{"summary":"Candidate Rejected","description":"Fired when a candidate is rejected from a project pipeline.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["candidate.rejected"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"candidateId":{"type":"string"},"person":{"type":"object","properties":{"id":{"type":"string"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"headlineRole":{"type":["string","null"]},"headlineCompanyName":{"type":["string","null"]}},"required":["id"]},"project":{"type":"object","properties":{"id":{"type":"string"},"jobRole":{"type":"string"},"jobNumber":{"type":["string","null"]},"state":{"type":"string"},"company":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]}},"required":["id","jobRole","state","company"]},"rejection":{"type":"object","properties":{"type":{"type":"string"},"reason":{"type":["string","null"]},"customReason":{"type":["string","null"]},"rejectedAt":{},"rejectedBy":{"type":["object","null"],"properties":{"userId":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"}},"required":["userId","email","name"]}},"required":["type","rejectedBy"]},"stage":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"phase":{"type":"string"}},"required":["id","name","phase"]},"status":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]}},"required":["candidateId","person","project","rejection","stage","status"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"candidate.stageMoved":{"post":{"summary":"Candidate Stage Moved","description":"Fired when a candidate is moved to a different stage or status within a project pipeline.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["candidate.stageMoved"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"candidateId":{"type":"string"},"personId":{"type":"string"},"projectId":{"type":"string"},"previousStage":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"phase":{"type":"string"}},"required":["id","name","type","phase"]},"previousStatus":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"newStage":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"phase":{"type":"string"}},"required":["id","name","type","phase"]},"newStatus":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"movedByUserId":{"type":["string","null"]},"movedAt":{}},"required":["candidateId","personId","projectId","previousStage","previousStatus","newStage","newStatus"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"placement.created":{"post":{"summary":"Placement Created","description":"Fired when a new placement (hire) is recorded.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["placement.created"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":["string","null"]},"startDate":{},"salary":{"type":["object","null"],"properties":{"value":{"type":["string","null"]},"currency":{"type":["string","null"]}}},"candidate":{"type":"object","properties":{"id":{"type":"string"},"personId":{"type":["string","null"]},"name":{"type":["string","null"]},"role":{"type":["string","null"]},"company":{"type":["string","null"]},"owner":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]}},"required":["id"]}},"required":["id","owner"]},"project":{"type":"object","properties":{"id":{"type":"string"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]}},"required":["id"]}},"required":["id","owner"]},"client":{"type":["object","null"],"properties":{"companyId":{"type":["string","null"]},"companyName":{"type":["string","null"]},"companyContact":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]}},"required":["id"]}},"required":["companyContact"]},"createdAt":{},"updatedAt":{}},"required":["id","salary","candidate","project","client"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"placement.updated":{"post":{"summary":"Placement Updated","description":"Fired when a placement record is updated.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["placement.updated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":["string","null"]},"startDate":{},"salary":{"type":["object","null"],"properties":{"value":{"type":["string","null"]},"currency":{"type":["string","null"]}}},"candidate":{"type":"object","properties":{"id":{"type":"string"},"personId":{"type":["string","null"]},"name":{"type":["string","null"]},"role":{"type":["string","null"]},"company":{"type":["string","null"]},"owner":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]}},"required":["id"]}},"required":["id","owner"]},"project":{"type":"object","properties":{"id":{"type":"string"},"owner":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]}},"required":["id"]}},"required":["id","owner"]},"client":{"type":["object","null"],"properties":{"companyId":{"type":["string","null"]},"companyName":{"type":["string","null"]},"companyContact":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]}},"required":["id"]}},"required":["companyContact"]},"createdAt":{},"updatedAt":{}},"required":["id","salary","candidate","project","client"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"project.created":{"post":{"summary":"Project Created","description":"Fired when a new project (job) is created.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["project.created"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"jobRole":{"type":"string"},"jobNumber":{"type":["string","null"]},"state":{"type":"string"},"closeReason":{"type":["string","null"]},"jobDescription":{"type":["string","null"]},"jobDescriptionFormatted":{"type":["string","null"]},"public":{"type":"boolean"},"contractType":{"type":["string","null"]},"workMode":{"type":["string","null"]},"seniority":{"type":["string","null"]},"func":{"type":["string","null"]},"skills":{"type":["array","null"],"items":{"type":"string"}},"hireTarget":{"type":["number","null"]},"visaSupport":{"type":["boolean","null"]},"salary":{"type":["string","null"]},"salaryCurrency":{"type":["string","null"]},"expectedFee":{"type":["string","null"]},"expectedFeeCurrency":{"type":["string","null"]},"feeTerms":{"type":["string","null"]},"location":{"type":["object","null"],"properties":{"raw":{"type":["string","null"]},"streetAddress":{"type":["string","null"]},"addressLine2":{"type":["string","null"]},"city":{"type":["string","null"]},"region":{"type":["string","null"]},"postalCode":{"type":["string","null"]},"country":{"type":["string","null"]},"metro":{"type":["string","null"]},"formattedAddress":{"type":["string","null"]},"latitude":{"type":["number","null"]},"longitude":{"type":["number","null"]}}},"company":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"industry":{"type":["string","null"]},"size":{"type":["string","null"]},"logoUrl":{"type":["string","null"]}},"required":["id","name"]},"owner":{"type":["object","null"],"properties":{"userId":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"}},"required":["userId","email","name"]},"members":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"},"memberType":{"type":"string","enum":["lead","member"]}},"required":["userId","email","name","memberType"]}},"stages":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"phase":{"type":"string"},"position":{"type":"number"},"statuses":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"position":{"type":"number"}},"required":["id","name","type","position"]}}},"required":["id","name","type","phase","position","statuses"]}},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string"},"attributeName":{"type":["string","null"]},"attributeType":{"type":["string","null"]},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string"},"optionValue":{"type":["string","null"]}},"required":["optionId"]}]}}},"required":["attributeId","values"]}},"createdAt":{},"startedAt":{},"closedAt":{}},"required":["id","jobRole","state","public","skills","location","company","owner","members","stages","customAttributes"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"project.updated":{"post":{"summary":"Project Updated","description":"Fired when a project is updated (e.g. state change, field edit).","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["project.updated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"jobRole":{"type":"string"},"jobNumber":{"type":["string","null"]},"state":{"type":"string"},"closeReason":{"type":["string","null"]},"jobDescription":{"type":["string","null"]},"jobDescriptionFormatted":{"type":["string","null"]},"public":{"type":"boolean"},"contractType":{"type":["string","null"]},"workMode":{"type":["string","null"]},"seniority":{"type":["string","null"]},"func":{"type":["string","null"]},"skills":{"type":["array","null"],"items":{"type":"string"}},"hireTarget":{"type":["number","null"]},"visaSupport":{"type":["boolean","null"]},"salary":{"type":["string","null"]},"salaryCurrency":{"type":["string","null"]},"expectedFee":{"type":["string","null"]},"expectedFeeCurrency":{"type":["string","null"]},"feeTerms":{"type":["string","null"]},"location":{"type":["object","null"],"properties":{"raw":{"type":["string","null"]},"streetAddress":{"type":["string","null"]},"addressLine2":{"type":["string","null"]},"city":{"type":["string","null"]},"region":{"type":["string","null"]},"postalCode":{"type":["string","null"]},"country":{"type":["string","null"]},"metro":{"type":["string","null"]},"formattedAddress":{"type":["string","null"]},"latitude":{"type":["number","null"]},"longitude":{"type":["number","null"]}}},"company":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"industry":{"type":["string","null"]},"size":{"type":["string","null"]},"logoUrl":{"type":["string","null"]}},"required":["id","name"]},"owner":{"type":["object","null"],"properties":{"userId":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"}},"required":["userId","email","name"]},"members":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"},"memberType":{"type":"string","enum":["lead","member"]}},"required":["userId","email","name","memberType"]}},"stages":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"phase":{"type":"string"},"position":{"type":"number"},"statuses":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"position":{"type":"number"}},"required":["id","name","type","position"]}}},"required":["id","name","type","phase","position","statuses"]}},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string"},"attributeName":{"type":["string","null"]},"attributeType":{"type":["string","null"]},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string"},"optionValue":{"type":["string","null"]}},"required":["optionId"]}]}}},"required":["attributeId","values"]}},"createdAt":{},"startedAt":{},"closedAt":{}},"required":["id","jobRole","state","public","skills","location","company","owner","members","stages","customAttributes"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"financial.feeCreated":{"post":{"summary":"Fee Created","description":"Fired when a fee is created on a project.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["financial.feeCreated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"projectId":{"type":"string"},"personId":{"type":["string","null"]},"placementId":{"type":["string","null"]},"createdById":{"type":"string"},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"deletedAt":{"type":["string","null"]}},"required":["id","name"]},"feeDate":{"type":["string","null"]},"amount":{"type":["string","null"]},"defaultAmount":{"type":["string","null"]},"currency":{"type":"string"},"defaultCurrency":{"type":"string"},"projectFeeStatus":{"type":"string"},"notes":{"type":["string","null"]},"externalInvoiceNumber":{"type":["string","null"]},"invoiceAccountCode":{"type":["string","null"]},"paidAt":{},"invoicedAt":{},"createdAt":{},"updatedAt":{},"splits":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"feeEarner":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]}},"required":["id"]},"feeType":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"deletedAt":{"type":["string","null"]}},"required":["id","name"]},"share":{"type":["string","null"]},"notes":{"type":["string","null"]}},"required":["id","feeEarner","feeType"]}}},"required":["id","projectId","createdById","feeType","currency","defaultCurrency","projectFeeStatus","splits"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"financial.feeUpdated":{"post":{"summary":"Fee Updated","description":"Fired when a project fee is updated.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["financial.feeUpdated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"projectId":{"type":"string"},"personId":{"type":["string","null"]},"placementId":{"type":["string","null"]},"createdById":{"type":"string"},"feeTypeId":{"type":["string","null"]},"feeTypeName":{"type":["string","null"]},"feeDate":{"type":["string","null"]},"currency":{"type":"string"},"amount":{"type":"string"},"agencyCurrency":{"type":"string"},"amountInAgencyCurrency":{"type":"string"},"projectFeeStatus":{"type":"string"},"notes":{"type":["string","null"]},"externalId":{"type":["string","null"]},"externalInvoiceNumber":{"type":["string","null"]},"invoiceAccountCode":{"type":["string","null"]},"startDate":{"type":["string","null"]},"endDate":{"type":["string","null"]},"paidAt":{"type":["string","null"]},"invoicedAt":{"type":["string","null"]},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"splits":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"feeEarnerId":{"type":"string"},"feeEarnerName":{"type":"string"},"feeEarnerEmail":{"type":"string"},"feeTypeId":{"type":["string","null"]},"feeTypeName":{"type":["string","null"]},"share":{"type":"string"},"notes":{"type":["string","null"]}},"required":["id","feeEarnerId","feeEarnerName","feeEarnerEmail","share"]}}},"required":["id","projectId","createdById","currency","amount","agencyCurrency","amountInAgencyCurrency","projectFeeStatus","createdAt","updatedAt","splits"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"client.created":{"post":{"summary":"Client Created","description":"Fired when a new company record is created.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["client.created"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"relationship":{"type":["string","null"]},"type":{"type":["string","null"]},"size":{"type":["string","null"]},"industry":{"type":["array","null"],"items":{"type":"string"}},"summary":{"type":["string","null"]},"overview":{"type":["string","null"]},"logo":{"type":["string","null"]},"employeeCount":{"type":["number","null"]},"ticker":{"type":["string","null"]},"location":{"type":["object","null"],"properties":{"raw":{"type":["string","null"]},"streetAddress":{"type":["string","null"]},"addressLine2":{"type":["string","null"]},"city":{"type":["string","null"]},"region":{"type":["string","null"]},"postalCode":{"type":["string","null"]},"country":{"type":["string","null"]},"metro":{"type":["string","null"]},"formattedAddress":{"type":["string","null"]},"latitude":{"type":["number","null"]},"longitude":{"type":["number","null"]}}},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"value":{"type":"string"},"primary":{"type":"boolean"}},"required":["type","value","primary"]}},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string"},"attributeName":{"type":["string","null"]},"attributeType":{"type":["string","null"]},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string"},"optionValue":{"type":["string","null"]}},"required":["optionId"]}]}}},"required":["attributeId","values"]}},"createdAt":{},"updatedAt":{}},"required":["id","name","industry","location","identities","customAttributes"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"client.updated":{"post":{"summary":"Client Updated","description":"Fired when a company record is updated.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["client.updated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"relationship":{"type":["string","null"]},"type":{"type":["string","null"]},"size":{"type":["string","null"]},"industry":{"type":["array","null"],"items":{"type":"string"}},"summary":{"type":["string","null"]},"overview":{"type":["string","null"]},"logo":{"type":["string","null"]},"employeeCount":{"type":["number","null"]},"ticker":{"type":["string","null"]},"location":{"type":["object","null"],"properties":{"raw":{"type":["string","null"]},"streetAddress":{"type":["string","null"]},"addressLine2":{"type":["string","null"]},"city":{"type":["string","null"]},"region":{"type":["string","null"]},"postalCode":{"type":["string","null"]},"country":{"type":["string","null"]},"metro":{"type":["string","null"]},"formattedAddress":{"type":["string","null"]},"latitude":{"type":["number","null"]},"longitude":{"type":["number","null"]}}},"identities":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"value":{"type":"string"},"primary":{"type":"boolean"}},"required":["type","value","primary"]}},"customAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string"},"attributeName":{"type":["string","null"]},"attributeType":{"type":["string","null"]},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string"},"optionValue":{"type":["string","null"]}},"required":["optionId"]}]}}},"required":["attributeId","values"]}},"createdAt":{},"updatedAt":{}},"required":["id","name","industry","location","identities","customAttributes"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"contract.created":{"post":{"summary":"Contract Created","description":"Fired when a new contract is created.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["contract.created"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"},"owner":{"type":["object","null"],"properties":{"userId":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]}},"required":["userId"]},"createdBy":{"type":["object","null"],"properties":{"userId":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]}},"required":["userId"]},"teams":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]}},"project":{"type":["object","null"],"properties":{"id":{"type":"string"},"jobRole":{"type":"string"},"jobNumber":{"type":["string","null"]}},"required":["id","jobRole"]},"contractor":{"type":["object","null"],"properties":{"personId":{"type":"string"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]}},"required":["personId"]},"clientCompany":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"contractTypes":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]}},"startDate":{},"endDate":{},"daysPerWeek":{"type":"number"},"hoursPerDay":{"type":"number"},"fixedDaysOverride":{"type":"boolean"},"fixedDays":{"type":["array","null"],"items":{"type":"string"}},"purchaseOrderNumber":{"type":["string","null"]},"assignment":{"type":["string","null"]},"payor":{"type":["object","null"],"properties":{"type":{"type":"string"},"company":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"invoiceFrequency":{"type":"string"},"paymentTermsDays":{"type":"number"},"noticePeriodDays":{"type":"number"}},"required":["type","company","invoiceFrequency","paymentTermsDays","noticePeriodDays"]},"payee":{"type":"object","properties":{"type":{"type":"string"},"company":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"contractorCompany":{"type":["object","null"],"properties":{"id":{"type":"string"},"entityName":{"type":"string"},"registrationNumber":{"type":["string","null"]}},"required":["id","entityName"]},"payInvoiceFrequency":{"type":"string"},"paymentTermsDays":{"type":"number"},"noticePeriodDays":{"type":"number"}},"required":["type","company","contractorCompany","payInvoiceFrequency","paymentTermsDays","noticePeriodDays"]},"parentContractId":{"type":["string","null"]},"rates":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"isBase":{"type":"boolean"},"unit":{"type":"string"},"chargeRate":{"type":"string"},"chargeCurrency":{"type":"string"},"payRate":{"type":"string"},"payCurrency":{"type":"string"},"baseCurrency":{"type":["string","null"]},"baseChargeRate":{"type":"string"},"basePayRate":{"type":"string"},"marginPercent":{"type":["string","null"]},"rateType":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"payBundle":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"config":{"type":"object","additionalProperties":{}}},"required":["id","name","config"]}},"required":["id","isBase","unit","chargeRate","chargeCurrency","payRate","payCurrency","baseChargeRate","basePayRate","rateType","payBundle"]}},"allowances":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"allowanceType":{"type":"string"},"amount":{"type":"string"},"currency":{"type":"string"},"unit":{"type":"string"},"capAmount":{"type":["string","null"]},"capUnit":{"type":["string","null"]},"notes":{"type":["string","null"]}},"required":["id","allowanceType","amount","currency","unit"]}},"contacts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"person":{"type":["object","null"],"properties":{"id":{"type":"string"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]}},"required":["id"]},"email":{"type":["string","null"]},"contactRole":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]}},"required":["id","person","contactRole"]}},"placementCustomAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string"},"attributeName":{"type":["string","null"]},"attributeType":{"type":["string","null"]},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string"},"optionValue":{"type":["string","null"]}},"required":["optionId"]}]}}},"required":["attributeId","values"]}},"createdAt":{},"updatedAt":{}},"required":["id","status","owner","createdBy","teams","project","contractor","clientCompany","contractTypes","daysPerWeek","hoursPerDay","fixedDaysOverride","fixedDays","payor","payee","rates","allowances","contacts","placementCustomAttributes"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}},"contract.updated":{"post":{"summary":"Contract Updated","description":"Fired when a contract is updated.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["contract.updated"],"description":"Event type identifier"},"occurredAt":{"type":"string","description":"ISO 8601 timestamp of when the event occurred"},"data":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"},"owner":{"type":["object","null"],"properties":{"userId":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]}},"required":["userId"]},"createdBy":{"type":["object","null"],"properties":{"userId":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]}},"required":["userId"]},"teams":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]}},"project":{"type":["object","null"],"properties":{"id":{"type":"string"},"jobRole":{"type":"string"},"jobNumber":{"type":["string","null"]}},"required":["id","jobRole"]},"contractor":{"type":["object","null"],"properties":{"personId":{"type":"string"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]}},"required":["personId"]},"clientCompany":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"contractTypes":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]}},"startDate":{},"endDate":{},"daysPerWeek":{"type":"number"},"hoursPerDay":{"type":"number"},"fixedDaysOverride":{"type":"boolean"},"fixedDays":{"type":["array","null"],"items":{"type":"string"}},"purchaseOrderNumber":{"type":["string","null"]},"assignment":{"type":["string","null"]},"payor":{"type":["object","null"],"properties":{"type":{"type":"string"},"company":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"invoiceFrequency":{"type":"string"},"paymentTermsDays":{"type":"number"},"noticePeriodDays":{"type":"number"}},"required":["type","company","invoiceFrequency","paymentTermsDays","noticePeriodDays"]},"payee":{"type":"object","properties":{"type":{"type":"string"},"company":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"contractorCompany":{"type":["object","null"],"properties":{"id":{"type":"string"},"entityName":{"type":"string"},"registrationNumber":{"type":["string","null"]}},"required":["id","entityName"]},"payInvoiceFrequency":{"type":"string"},"paymentTermsDays":{"type":"number"},"noticePeriodDays":{"type":"number"}},"required":["type","company","contractorCompany","payInvoiceFrequency","paymentTermsDays","noticePeriodDays"]},"parentContractId":{"type":["string","null"]},"rates":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"isBase":{"type":"boolean"},"unit":{"type":"string"},"chargeRate":{"type":"string"},"chargeCurrency":{"type":"string"},"payRate":{"type":"string"},"payCurrency":{"type":"string"},"baseCurrency":{"type":["string","null"]},"baseChargeRate":{"type":"string"},"basePayRate":{"type":"string"},"marginPercent":{"type":["string","null"]},"rateType":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"payBundle":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"config":{"type":"object","additionalProperties":{}}},"required":["id","name","config"]}},"required":["id","isBase","unit","chargeRate","chargeCurrency","payRate","payCurrency","baseChargeRate","basePayRate","rateType","payBundle"]}},"allowances":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"allowanceType":{"type":"string"},"amount":{"type":"string"},"currency":{"type":"string"},"unit":{"type":"string"},"capAmount":{"type":["string","null"]},"capUnit":{"type":["string","null"]},"notes":{"type":["string","null"]}},"required":["id","allowanceType","amount","currency","unit"]}},"contacts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"person":{"type":["object","null"],"properties":{"id":{"type":"string"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]}},"required":["id"]},"email":{"type":["string","null"]},"contactRole":{"type":["object","null"],"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]}},"required":["id","person","contactRole"]}},"placementCustomAttributes":{"type":"array","items":{"type":"object","properties":{"attributeId":{"type":"string"},"attributeName":{"type":["string","null"]},"attributeType":{"type":["string","null"]},"values":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"optionId":{"type":"string"},"optionValue":{"type":["string","null"]}},"required":["optionId"]}]}}},"required":["attributeId","values"]}},"createdAt":{},"updatedAt":{}},"required":["id","status","owner","createdBy","teams","project","contractor","clientCompany","contractTypes","daysPerWeek","hoursPerDay","fixedDaysOverride","fixedDays","payor","payee","rates","allowances","contacts","placementCustomAttributes"]}},"required":["event","occurredAt","data"]}}}},"responses":{"200":{"description":"Return any 2xx status to acknowledge receipt of the webhook."}}}}}}