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

# Upsert Recipients

> Idempotent add-or-update — the call to sync your user base against

Adds or updates up to 1000 recipients per request. This is the endpoint a signup hook, nightly job or Zapier/n8n flow should call: sending the same payload twice is harmless, and sending a changed one updates the contact in place. See the [sync guide](/guides/sync-mailing-list) for end-to-end recipes.

How each entry is matched and what happens:

| The entry… | Result |
| - | - |
| has an `external_id` that is already on the list | That recipient is updated — including its email, if it changed |
| has an email that is on the list with no `external_id` | That recipient is updated and adopts the `external_id` |
| matches nothing | Added as a new active recipient |
| matches a `removed` recipient | Reactivated, then updated |
| matches an `unsubscribed` recipient | Left completely untouched; counted in `skipped_unsubscribed` |
| has an email that belongs to a *different* `external_id` | Reported in `conflicts`, nothing changed |

`name` and `external_id` are only written when you send them. `attributes` replaces the stored map when present. Upsert never deletes: remove departed users with [Remove Recipient](/api-reference/mailing-lists/remove-recipient).

## Authentication

This endpoint requires an API token passed as a Bearer token in the `Authorization` header.

```bash theme={null}
Authorization: Bearer YOUR_API_TOKEN
```

## Path Parameters

<ParamField path="list_id" type="string" required>
  The mailing list's unique identifier (UUID).
</ParamField>

## Body

<ParamField body="recipients" type="(string | object)[]" required>
  1–1000 entries. A plain string is an email address. An object has:

  <Expandable title="Recipient object">
    <ParamField body="email" type="string" required>Email address (case-insensitive)</ParamField>
    <ParamField body="name" type="string">Display name, up to 200 characters. Used for the greeting in release emails.</ParamField>
    <ParamField body="external_id" type="string">Your own id for this person (user id, customer id), up to 255 characters, unique per list. Key by it so an email change updates the same recipient.</ParamField>
    <ParamField body="attributes" type="object">Up to 20 keys (≤ 64 chars each) with string, number, boolean or null values; at most 2 KB serialized. Replaces the stored map.</ParamField>
  </Expandable>
</ParamField>

## Request

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PUT "https://api.shipstar.ai/api/v1/email/lists/a1b2c3d4-e5f6-7890-abcd-ef1234567890/recipients" \
    -H "Authorization: Bearer YOUR_API_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "recipients": [
        {"email": "sam@example.com", "name": "Sam Lee", "external_id": "usr_42", "attributes": {"plan": "pro"}},
        "casey@example.com"
      ]
    }'
  ```

  ```javascript JavaScript theme={null}
  const listId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
  const response = await fetch(`https://api.shipstar.ai/api/v1/email/lists/${listId}/recipients`, {
    method: 'PUT',
    headers: {
      'Authorization': 'Bearer YOUR_API_TOKEN',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      recipients: [
        { email: 'sam@example.com', name: 'Sam Lee', external_id: 'usr_42', attributes: { plan: 'pro' } },
        'casey@example.com'
      ]
    })
  });

  const result = await response.json();
  ```

  ```python Python theme={null}
  import requests

  list_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
  response = requests.put(
      f'https://api.shipstar.ai/api/v1/email/lists/{list_id}/recipients',
      headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
      json={'recipients': [
          {'email': 'sam@example.com', 'name': 'Sam Lee', 'external_id': 'usr_42',
           'attributes': {'plan': 'pro'}},
          'casey@example.com',
      ]},
  )

  result = response.json()
  ```
</CodeGroup>

## Response

<ResponseField name="added" type="integer" required>New recipients created</ResponseField>
<ResponseField name="updated" type="integer" required>Existing active recipients whose fields changed</ResponseField>
<ResponseField name="reactivated" type="integer" required>Removed recipients put back on the list</ResponseField>
<ResponseField name="unchanged" type="integer" required>Entries that matched an active recipient with identical fields</ResponseField>
<ResponseField name="skipped_unsubscribed" type="integer" required>Entries that matched a recipient who unsubscribed; nothing was changed</ResponseField>
<ResponseField name="invalid" type="string[]" required>Entries whose email failed validation</ResponseField>

<ResponseField name="conflicts" type="object[]" required>
  Entries that could not be applied, each with `email`, `external_id` and a `reason`.
</ResponseField>

```json 200 theme={null}
{
  "added": 1,
  "updated": 1,
  "reactivated": 0,
  "unchanged": 0,
  "skipped_unsubscribed": 0,
  "invalid": [],
  "conflicts": []
}
```

## Errors

| Status | Description |
| - | - |
| 400 | Applying the batch would exceed 500 active recipients (nothing is written; split the batch or remove contacts first) |
| 401 | Invalid or expired API token |
| 404 | Mailing list not found in the token's project |
| 422 | Invalid request body (empty array, more than 1000 entries, oversize attributes) |

## Rate Limits

This endpoint is limited to 30 requests per minute per IP.
