---
title: "Create or update companies in bulk (upsert)"
method: POST
path: "/api/v1/companies/batch/upsert"
tags: ["Companies"]
---

# Create or update companies in bulk (upsert)

`POST /api/v1/companies/batch/upsert`

## Request

- Base URL: `https://api.recruitwithatlas.com`
- URL: `https://api.recruitwithatlas.com/api/v1/companies/batch/upsert`
- Auth: HTTP bearer
- TypeScript: <https://skmtc.dev/recruitwithatlas/apis/atlas-api/docs/api/v1/companies/batch/upsert/post.ts> (a function with typed inputs; its response is checked against the schema)

Upserts up to 50 companies under the caller's agency in one request. Each item follows the same field rules as `POST /api/v1/companies`, and must carry at least one match key: `id`, `websiteUrl`, or `linkedinUrl`.

**Matching.** `id` wins: when an item carries an `id`, it is authoritative and update-only — an unknown id fails the item (a company is never created with a caller-supplied id), and a supplied `websiteUrl`/`linkedinUrl` owned by a *different* company fails the item. Without an `id`, Atlas looks for an existing company owning the supplied `linkedinUrl` (the more reliable identifier, checked first) or `websiteUrl`. A match **updates** that company; no match **creates** one — use `POST /api/v1/companies/batch` instead when existing companies must be reported as failures rather than updated.

**Update semantics.** Only fields present on the item are written — an omitted field is left unchanged, `null` clears a nullable field, and custom attributes are replaced per attribute (as in `PATCH /companies/{id}`). `websiteUrl` / `linkedinUrl` act as match keys only: an update never adds or modifies the company’s identities. `relationship` defaults to `none` only when the item creates a company — omitted on an update it leaves the stored relationship unchanged; note that `none` companies are excluded from the default `GET /api/v1/companies`, so send `client` or `target` to have a newly created company appear there. An `industry` label matching the Atlas industry dictionary is stored in the dictionary’s canonical casing; any other label is stored exactly as sent — the rule is the same on create and update, since the caller cannot know up front which branch an item takes. (`PATCH /companies/{id}` is stricter: it rejects labels outside the dictionary.)

Items are processed independently and run a few at a time, so they do not necessarily complete in request order; results are always reported in request order via `index`. An item that fails — e.g. its `websiteUrl` and `linkedinUrl` resolve to two different existing companies, it references an unknown custom attribute, or an unexpected internal error occurs — is reported with `status: "failed"` and does not abort the remaining items. Internal errors carry a generic `error` message and are safe to retry **only when the message says the item was not processed**; when the failure happened after a company row was already persisted, the error says so and includes the created company id — do not retry that item as-is; verify the company and complete any missing fields via `PATCH /api/v1/companies/{id}` instead.

The response is always `200` with one result per input item (matched by `index`, the zero-based position in the request array) plus a `summary` of created/updated/failed counts. A request-level validation error (malformed item, more than 50 items, or the same company named by two items — see the request body) is rejected as a whole with `422` and writes nothing. Each successful item returns the company `id` only — read full details via `GET /api/v1/companies/{id}`.

## Request body

- object
  - `companies` object[], required: Companies to create or update (1–50 items). Each item has the same shape as the POST /companies body, plus an optional `id` targeting an exact company to update. A company may be named at most once per batch: two items sharing an `id`, a normalized websiteUrl or a normalized linkedinUrl reject the whole request with 422 before anything is written — otherwise the two would silently collapse into a last-write-wins update.
    - `name` string, required: Company name (max 500 characters)
    - `websiteUrl` string, nullable: 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.
    - `linkedinUrl` string, nullable: LinkedIn company URL. Stored as a CompanyIdentity of type `linkedin`. At least one of `websiteUrl` or `linkedinUrl` is required.
    - `summary` string, nullable: Short company summary. Empty strings are rejected — omit or send null to clear.
    - `overview` string, nullable: Long-form company description. Empty strings are rejected — omit or send null to clear.
    - `size` '1-10' | '11-50' | '51-200' | '201-500' | '501-1000' | '1001-5000' | '5001-10000' | '10001+' | 'null', nullable: Company size bucket
    - `industry` string[], nullable: Industry labels (overwrites the existing set when the item updates). A label matching the Atlas industry dictionary is stored in its canonical casing; any other label is stored exactly as sent — the rule is the same whether the item creates or updates, since the caller cannot know which branch an item takes. Duplicates are silently dropped. Send null (or []) to clear.
    - `relationship` 'client' | 'target' | 'none': Company relationship: `client` — an active client; `target` — a company you are targeting for new business; `none` — no commercial relationship (the default, and the value of companies auto-created from a person’s work history). `target` is a company-only value: a contact’s `relationship` uses `prospect` for the same idea. See "Relationship values" in the introduction. Defaults to `none` when the item creates a company; when the item updates an existing company, an omitted value leaves the stored relationship unchanged. `none` companies are excluded from the default `GET /api/v1/companies` — send `client` or `target` on create to have the company appear in the default list.
    - `type` 'educational' | 'government' | 'nonprofit' | 'private' | 'public' | 'null', nullable: Company type
    - `employeeCount` integer, nullable: Number of employees (precise, integer)
    - `ticker` string, nullable: Stock ticker symbol. Empty strings are rejected — omit or send null to clear.
    - `location` object, nullable: Location
      - `name` string, nullable: Display name / formatted address (e.g. "London, UK")
      - `locality` string, nullable: City name
      - `region` string, nullable: State or region
      - `metro` string, nullable: Metro area (People Data Labs convention, e.g. "new york, new york")
      - `country` string, nullable: Country name
      - `streetAddress` string, nullable: Street address line 1
      - `addressLine2` string, nullable: Street address line 2 (apartment, suite, unit, etc.)
      - `postalCode` string, nullable: Postal / ZIP code
      - `raw` string, nullable: Raw unstructured address text (used for geocoding)
      - `latitude` number, nullable: Latitude coordinate (-90..90)
      - `longitude` number, nullable: Longitude coordinate (-180..180)
    - `customAttributes` object[]: Custom attribute values. For options-type attributes with multipleValues=true, repeat the same customAttributeId with distinct optionIds.
      - `customAttributeId` string, uuid, required: Custom attribute definition ID — must belong to the agency at the correct scope.
      - `optionId` string, uuid, nullable: 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` union: Attribute value. Shape depends on the attribute `type`: - `text_line` / `text_block`: non-empty string (trimmed, max 16384 chars). - `integer` / `number_input`: JSON number, integer only, must fit Postgres int32 (-2147483648..2147483647). - `date`: string `YYYY-MM-DD`, calendar-validated. - `options`: do not send `value` — use `optionId` instead. Mutually exclusive with `optionId`. Sending neither is rejected (422).
        - string
        - number
    - `id` string, uuid: Atlas company id to update. When present the item is update-only and the id is authoritative: an unknown id fails the item (a company is never created with a caller-supplied id), and a supplied websiteUrl/linkedinUrl owned by a different company fails the item. When present, websiteUrl/linkedinUrl become optional.

## Response `200`

Batch processed — check each result item (and the summary) for per-item outcomes

- object
  - `status` 'ok', required
  - `data` object[], required
    - `index` integer, required: Zero-based position of the item in the request `companies` array
    - `status` 'created' | 'updated' | 'failed', required: Outcome for this item
    - `id` string, uuid: Company id (present when status is `created` or `updated`)
    - `error` string: Failure reason (present when status is `failed`)
  - `summary` object, required
    - `total` integer, required: Number of items in the request
    - `created` integer, required: Number of companies created
    - `updated` integer, required: Number of existing companies updated
    - `failed` integer, required: Number of items that failed

## Other responses

- `401`: Unauthorized - missing or invalid API key
- `422`: Validation error - the request failed schema validation (`errors.fieldErrors`) or a business rule (`errors.formErrors`)
- `429`: 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.
- `500`: Internal server error

## Changes

- **2026-11-03** `158ee454751c`: 2 info
  - added the new `undefined` enum value to the request property `companies/items/size`
  - added the new `undefined` enum value to the request property `companies/items/type`
- **2026-10-03** `cd4bc8bbd95e`: 1 info
  - added the optional property `error` to the response with the `422` status
- **2026-09-29** `d7ef7f970d5b`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/recruitwithatlas/apis/atlas-api/changes/api/v1/companies/batch/upsert/post.md)

---

[API](https://skmtc.dev/recruitwithatlas/apis/atlas-api.md) - [All operations](https://skmtc.dev/recruitwithatlas/apis/atlas-api/llms.txt) - [OpenAPI document](https://skmtc.dev/recruitwithatlas/apis/atlas-api/revisions/65a1374ebd70?raw)
