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

# Bulk upsert users

> POST /api/v1/users/bulk &mdash; backfill up to 1,000 users in one request.

The backfill counterpart to [single-record upsert](/api-reference/users/upsert). Send up to **1,000 user records** in one POST; per-record validation errors are surfaced in the response, the rest are queued.

<Info>
  **SDK is the recommended path.** [`vf.users.identifyBulk()`](/sdk/resources/users#identifybulk-records) wraps this endpoint with strong typing and pairs cleanly with the exported [`chunk()`](/sdk/types#chunk-input-size) helper for backfills larger than 1,000 records.
</Info>

<Snippet file="auth-headers.mdx" />

## Request

```http theme={null}
POST /api/v1/users/bulk HTTP/1.1
Host: api.vibefollow.com
Authorization: Bearer sk_live_••••
Content-Type: application/json
```

### Body

```json theme={null}
{
  "users": [
    {
      "external_user_id": "usr_42",
      "email": "jane@acme.io",
      "traits": { "name": "Jane Doe", "plan": "pro" }
    },
    {
      "external_user_id": "usr_43",
      "email": "noel@acme.io",
      "traits": { "name": "Noel Carter", "plan": "trial" }
    }
  ]
}
```

<ParamField body="users" type="User[]" required>
  Array of user records. Each entry has the same shape as the [single-record body](/api-reference/users/upsert#body). **Minimum 1, maximum 1,000 records.**
</ParamField>

## Response

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json
```

```json theme={null}
{
  "data": {
    "accepted": 487,
    "rejected": 13,
    "errors": [
      { "index": 12, "code": "validation", "message": "Invalid email", "field": "email" },
      { "index": 47, "code": "validation", "message": "external_user_id must be at least 1 character" }
    ]
  },
  "meta": { "received": 500 }
}
```

<ResponseField name="data.accepted" type="number">
  Records that passed validation and were enqueued.
</ResponseField>

<ResponseField name="data.rejected" type="number">
  Records that failed validation. Each has a corresponding entry in `data.errors`.
</ResponseField>

<ResponseField name="data.errors" type="object[]">
  Per-record validation failures. `index` correlates back to the position in the input `users` array — the record at that position was **not** enqueued.

  <Expandable title="Error object">
    <ParamField path="index" type="number" required>
      0-based position in the request `users` array.
    </ParamField>

    <ParamField path="code" type="string" required>
      Machine-readable error code (e.g. `"validation"`).
    </ParamField>

    <ParamField path="message" type="string" required>
      Human-readable detail.
    </ParamField>

    <ParamField path="field" type="string">
      Field path inside the record that failed, when applicable (e.g. `"email"`).
    </ParamField>
  </Expandable>
</ResponseField>

<ResponseField name="meta.received" type="number">
  Total records in the request payload. `accepted + rejected === meta.received` is an invariant — every record is accounted for.
</ResponseField>

<Note>
  **Idempotency is automatic.** The `(project_id, external_user_id)` uniqueness constraint on `tracked_users` means re-sending the same record with the same `external_user_id` is treated as an update, not a duplicate. Any chunk that returns `429` or `5xx` is safe to retry.
</Note>

## Examples

<CodeGroup>
  ```ts Node (SDK) theme={null}
  import { VibeFollow, chunk } from '@vibefollow/sdk';

  const vf = new VibeFollow({ apiKey: process.env.VIBEFOLLOW_API_KEY! });

  // Backfill any size by pairing with chunk()
  for (const slice of chunk(allUsers, 1000)) {
    const result = await vf.users.identifyBulk(slice);
    if (result.data.errors.length > 0) {
      console.warn('skipped', result.data.errors);
    }
  }
  ```

  ```bash cURL theme={null}
  curl https://api.vibefollow.com/api/v1/users/bulk \
    -X POST \
    -H "Authorization: Bearer $VIBEFOLLOW_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "users": [
        { "external_user_id": "usr_42", "email": "jane@acme.io" },
        { "external_user_id": "usr_43", "email": "noel@acme.io" }
      ]
    }'
  ```
</CodeGroup>

## Common errors

<AccordionGroup>
  <Accordion title="401 auth_required" icon="key">
    Missing or malformed `Authorization` header.
  </Accordion>

  <Accordion title="400 validation_failed" icon="circle-exclamation">
    The **envelope** failed validation — `users` is missing, not an array, empty, or contains more than 1,000 records. Per-record validation failures do **not** trigger 400; they surface in `data.errors`.
  </Accordion>

  <Accordion title="429 rate_limited" icon="gauge-high">
    Standard per-project rate limit; honor `Retry-After`.
  </Accordion>

  <Accordion title="429 backfill_queue_full" icon="layer-group">
    The project's ingest queue is saturated (50,000+ jobs waiting or delayed). Back off, drain, then resume in smaller chunks.
  </Accordion>

  <Accordion title="5xx server_error" icon="server">
    Retry the same chunk — the upsert is idempotent via the unique `(project_id, external_user_id)` constraint.
  </Accordion>
</AccordionGroup>

## Sizing

<CardGroup cols={3}>
  <Card title="Max records per request" icon="layer-group">
    1,000
  </Card>

  <Card title="Recommended chunk size" icon="ruler-horizontal">
    1,000
  </Card>

  <Card title="Backfill queue cap" icon="gauge">
    50,000 waiting jobs / project
  </Card>
</CardGroup>
