> ## Documentation Index
> Fetch the complete documentation index at: https://docs.corafone.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Bulk upsert accounts

> Create or update up to 500 accounts by externalId.

## Endpoint

`POST /external-api/accounts/bulk-upsert`

## Base path

`/external-api/accounts`

## What it does

Bulk upsert creates new accounts or updates existing accounts by `externalId`.

The organization is resolved from the API key. Do not send `organizationId` in account objects.

## Headers

* `Authorization: Bearer <org_api_key>`
* `X-Cora-Timestamp: <unix_seconds_or_ms>`
* `X-Cora-Signature: <hmac_sha256_hex>`
* `Content-Type: application/json`

See [Authentication & Signing](/authentication) for the HMAC signing algorithm. The signed method for this endpoint is `POST`, and `path_with_query` is `/external-api/accounts/bulk-upsert`.

## Curl example

```bash theme={null}
curl -sS \
  -X POST "https://api.corafone.com/external-api/accounts/bulk-upsert" \
  -H "Authorization: Bearer cora_org_<keyId>.<secret>" \
  -H "X-Cora-Timestamp: <unix_seconds_or_ms>" \
  -H "X-Cora-Signature: <hmac_sha256_hex>" \
  -H "Content-Type: application/json" \
  --data '{"dryRun":false,"accounts":[{"externalId":"FILE_123","phoneNumber":"+15555550111","portfolioId":"PORTFOLIO_ID","bucket":"BUCKET_NAME","metadata":{"filenumber":"FILE_123"},"currentBalance":450.25}]}'
```

## Request body

```json theme={null}
{
  "dryRun": false,
  "includeChanges": false,
  "accounts": [
    {
      "externalId": "FILE_123",
      "phoneNumber": "+15555550111",
      "portfolioId": "PORTFOLIO_ID",
      "bucket": "BUCKET_NAME",
      "metadata": {
        "filenumber": "FILE_123",
        "externalApiSyncState": "SYNCED"
      },
      "currentBalance": 450.25
    },
    {
      "externalId": "FILE_456",
      "currentBalance": 125.5
    }
  ]
}
```

## Body fields

| Field            | Type              | Required | Notes                                                                             |
| ---------------- | ----------------- | -------- | --------------------------------------------------------------------------------- |
| `accounts`       | `AccountUpsert[]` | `true`   | One to 500 account objects.                                                       |
| `dryRun`         | `boolean`         | `false`  | When `true`, validates and returns the same result shape without writing changes. |
| `includeChanges` | `boolean`         | `false`  | When `true`, changed existing accounts include a `changes` array in their result. |

## Account fields

Each account must include `externalId`.

For new accounts, these fields are also required:

* `phoneNumber`
* `portfolioId`
* `metadata.filenumber`

Bulk upsert accepts the same account update fields as [Update one account](/accounts-update-one), plus `externalId`, `portfolioId`, and optional `bucket`. `portfolioId` is used when creating a new account; it is not updated on existing accounts. `bucket` can be provided when creating a new account and can also be updated on existing accounts through bulk upsert.

## Rules

* Maximum 500 accounts per request.
* Rate limited to 1 request every 20 seconds per API key/IP bucket.
* Each `externalId` can appear only once per request.
* Existing accounts are matched by `externalId` within the API key's organization.
* `phoneNumber` must be E.164 format, for example `+15555550123`.
* Object fields such as `metadata`, `person`, and `address` are deep-merged when updating existing accounts.
* Scalars and arrays replace existing values.
* Results are partial-success: valid rows can be created or updated while other rows fail.

## Dry run

Use `dryRun: true` to validate a batch and see which rows would be created, updated, unchanged, or failed.

```json theme={null}
{
  "dryRun": true,
  "includeChanges": true,
  "accounts": [
    {
      "externalId": "FILE_123",
      "phoneNumber": "+15555550111",
      "portfolioId": "PORTFOLIO_ID",
      "bucket": "BUCKET_NAME",
      "metadata": {
        "filenumber": "FILE_123"
      }
    }
  ]
}
```

## Success response

The endpoint returns `200` when the bulk request is processed. Check the summary counts and per-row `results` to see which accounts succeeded or failed.

```json theme={null}
{
  "success": true,
  "code": "ACCOUNTS_BULK_UPSERT_COMPLETED",
  "message": "Bulk account upsert completed",
  "dryRun": false,
  "requested": 2,
  "created": 1,
  "updated": 1,
  "unchanged": 0,
  "failed": 0,
  "results": [
    {
      "index": 0,
      "externalId": "FILE_123",
      "status": "created",
      "accountId": "ACCOUNT_ID",
      "updatedAt": "2026-02-23T12:45:00.000Z"
    },
    {
      "index": 1,
      "externalId": "FILE_456",
      "status": "updated",
      "accountId": "ACCOUNT_ID",
      "updatedAt": "2026-02-23T12:45:00.000Z"
    }
  ]
}
```

## Result object

| Field        | Type                                        | Notes                                                                                                      |
| ------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `index`      | `number`                                    | Zero-based index of the account in the request.                                                            |
| `externalId` | `string`                                    | External account identifier, when available.                                                               |
| `status`     | `created \| updated \| unchanged \| failed` | Per-row outcome.                                                                                           |
| `accountId`  | `string`                                    | Cora account id for successful existing or created rows.                                                   |
| `updatedAt`  | `ISO datetime string`                       | Last update timestamp when available.                                                                      |
| `code`       | `string`                                    | Machine-readable error code for failed rows.                                                               |
| `message`    | `string`                                    | Human-readable error message for failed rows.                                                              |
| `details`    | `object[]`                                  | Field-level details for validation failures.                                                               |
| `changes`    | `object[]`                                  | Included for updated rows when `includeChanges` is `true`. Each change includes `field`, `from`, and `to`. |

## Change example

```json theme={null}
{
  "index": 1,
  "externalId": "FILE_456",
  "status": "updated",
  "accountId": "ACCOUNT_ID",
  "updatedAt": "2026-02-23T12:45:00.000Z",
  "changes": [
    {
      "field": "currentBalance",
      "from": 100,
      "to": 125.5
    }
  ]
}
```

## Per-row failure example

```json theme={null}
{
  "index": 2,
  "externalId": "FILE_789",
  "status": "failed",
  "code": "MISSING_REQUIRED_FIELD",
  "message": "Missing required fields for account creation: phoneNumber, portfolioId, metadata.filenumber",
  "details": [
    {
      "field": "phoneNumber",
      "message": "phoneNumber is required when creating an account"
    },
    {
      "field": "portfolioId",
      "message": "portfolioId is required when creating an account"
    },
    {
      "field": "metadata.filenumber",
      "message": "metadata.filenumber is required when creating an account"
    }
  ]
}
```

## Request-level errors

These errors stop the whole request:

* `INVALID_REQUEST_BODY`: request body does not include an `accounts` array.
* `ACCOUNTS_REQUIRED`: `accounts` is empty.
* `ACCOUNTS_LIMIT_EXCEEDED`: more than 500 accounts were sent.
* `429 Too Many Requests`: more than 1 bulk-upsert request was sent within 20 seconds for the same API key/IP bucket.
* `MISSING_AUTH_HEADERS`: one or more signed request auth headers are missing.
* `INVALID_API_KEY`: API key is invalid, revoked, or not found.
* `REQUEST_TIMESTAMP_OUTSIDE_WINDOW`: timestamp is outside the 5-minute window.
* `INVALID_REQUEST_SIGNATURE`: HMAC signature does not match the request payload.
* `METHOD_NOT_ALLOWED`: method is not `POST`.
