> ## 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.

# Sync Your Users into a Mailing List

> Keep a Shipstar mailing list in step with your own user base — signup hooks, nightly jobs, Zapier/n8n, and one-off CSV imports

## Overview

A Shipstar mailing list is the audience for your release-notes emails. You can fill one by hand in the dashboard, but for a real product the list should mirror your own users: someone signs up and they start getting release notes; they close their account and the emails stop; they change their address and the next issue reaches the new one.

The public API gives you three idempotent calls for that, and the dashboard covers the one-off import and export:

| Need | Call |
| - | - |
| Add or update contacts (signup, profile change, nightly reconcile) | [`PUT /email/lists/{id}/recipients`](/api-reference/mailing-lists/upsert-recipients) |
| Remove a contact (account closed, plan downgraded) | [`DELETE /email/lists/{id}/recipients/{email-or-external_id}`](/api-reference/mailing-lists/remove-recipient) or the batch `POST .../recipients/remove` |
| Check what is on the list | [`GET /email/lists/{id}/recipients`](/api-reference/mailing-lists/list-recipients), [`GET /email/lists/{id}`](/api-reference/mailing-lists/get-mailing-list) |
| Initial load or backup | **Import CSV** / **Export CSV** in the dashboard (Destinations → Email → the list), or [`GET .../recipients/export.csv`](/api-reference/mailing-lists/export-recipients) |

All calls take an [API token](/guides/authentication) scoped to the project. List ids come from [`GET /email/lists`](/api-reference/mailing-lists/list-mailing-lists), or create one with [`POST /email/lists`](/api-reference/mailing-lists/create-mailing-list). Management calls are free; only content generation spends credits.

## The four things to know

**Key by `external_id`.** Send your own user id as `external_id` on every upsert. Shipstar then matches on it first, so when a user changes their email address the same recipient is updated in place instead of a second one appearing. Without it, matching falls back to the email address, which still works for simple lists.

**Three statuses.** A recipient is `active` (gets emails), `removed` (you took them off; an upsert puts them back), or `unsubscribed` (they clicked the link in an email). Unsubscribed is terminal: no API call ever changes those rows, they just come back in the response as `skipped_unsubscribed`. Your sync does not need to track opt-outs itself.

**Upsert never deletes.** `PUT` only adds and updates. Departed users have to be removed explicitly — either one at a time from your account-deletion path, or by diffing in a reconcile job (below).

**Retries are safe.** Every call is idempotent per entry. A re-sent batch reports the repeats as `unchanged` / `already_removed`. Lists hold at most 500 active recipients; an upsert that would cross that line is rejected as a whole with a 400 so you can split it.

## Recipe 1 — Signup and account-deletion hooks

The simplest integration: call the API from the code paths where a user is created, updated or deleted.

<CodeGroup>
  ```python Python theme={null}
  import os
  import requests

  SHIPSTAR = "https://api.shipstar.ai/api/v1"
  LIST_ID = os.environ["SHIPSTAR_LIST_ID"]
  HEADERS = {"Authorization": f"Bearer {os.environ['SHIPSTAR_API_TOKEN']}"}


  def on_user_created_or_updated(user):
      requests.put(
          f"{SHIPSTAR}/email/lists/{LIST_ID}/recipients",
          headers=HEADERS,
          json={"recipients": [{
              "email": user.email,
              "name": user.full_name,
              "external_id": str(user.id),
              "attributes": {"plan": user.plan},
          }]},
          timeout=10,
      ).raise_for_status()


  def on_user_deleted(user):
      # 404 just means they were never on the list
      requests.delete(
          f"{SHIPSTAR}/email/lists/{LIST_ID}/recipients/{user.id}",
          headers=HEADERS,
          timeout=10,
      )
  ```

  ```typescript TypeScript theme={null}
  const SHIPSTAR = "https://api.shipstar.ai/api/v1";
  const LIST_ID = process.env.SHIPSTAR_LIST_ID!;
  const headers = {
    Authorization: `Bearer ${process.env.SHIPSTAR_API_TOKEN}`,
    "Content-Type": "application/json",
  };

  export async function onUserCreatedOrUpdated(user: { id: string; email: string; name?: string; plan: string }) {
    await fetch(`${SHIPSTAR}/email/lists/${LIST_ID}/recipients`, {
      method: "PUT",
      headers,
      body: JSON.stringify({
        recipients: [{ email: user.email, name: user.name, external_id: user.id, attributes: { plan: user.plan } }],
      }),
    });
  }

  export async function onUserDeleted(userId: string) {
    await fetch(`${SHIPSTAR}/email/lists/${LIST_ID}/recipients/${encodeURIComponent(userId)}`, {
      method: "DELETE",
      headers,
    });
  }
  ```
</CodeGroup>

Run these after your own transaction commits, ideally from a background job so a Shipstar hiccup never fails a signup. If the call fails, retrying later is safe.

## Recipe 2 — Nightly reconcile from your database

Hooks can miss events (a bulk import into your own database, a migration). A scheduled job that pushes the current truth and removes what is no longer there catches everything:

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

SHIPSTAR = "https://api.shipstar.ai/api/v1"
LIST_ID = os.environ["SHIPSTAR_LIST_ID"]
HEADERS = {"Authorization": f"Bearer {os.environ['SHIPSTAR_API_TOKEN']}"}


def chunks(items, size):
    for i in range(0, len(items), size):
        yield items[i : i + size]


def reconcile(users):
    """`users` is every account that should receive release notes."""
    wanted = {
        str(u.id): {"email": u.email, "name": u.full_name, "external_id": str(u.id)}
        for u in users
    }

    # 1. Push the current truth (adds, updates, reactivates removed ones)
    for batch in chunks(list(wanted.values()), 1000):
        r = requests.put(
            f"{SHIPSTAR}/email/lists/{LIST_ID}/recipients",
            headers=HEADERS, json={"recipients": batch}, timeout=30,
        )
        r.raise_for_status()

    # 2. Remove active recipients that are no longer in your user base
    stale, cursor = [], None
    while True:
        params = {"status": "active", "limit": 500, **({"cursor": cursor} if cursor else {})}
        page = requests.get(
            f"{SHIPSTAR}/email/lists/{LIST_ID}/recipients",
            headers=HEADERS, params=params, timeout=30,
        ).json()
        stale += [r["external_id"] for r in page["items"]
                  if r["external_id"] and r["external_id"] not in wanted]
        cursor = page["next_cursor"]
        if not cursor:
            break
    for batch in chunks(stale, 1000):
        requests.post(
            f"{SHIPSTAR}/email/lists/{LIST_ID}/recipients/remove",
            headers=HEADERS, json={"external_ids": batch}, timeout=30,
        ).raise_for_status()
```

Recipients without an `external_id` (added by hand or by an email-only import) are left alone by step 2, so a human-curated list and a synced list can coexist.

Write calls are limited to 30 per minute per IP, which is 30,000 recipients a minute in 1000-entry batches — more than a list can hold. Read the `RateLimit-Remaining` header if you run several syncs from one host.

## Recipe 3 — Zapier, n8n or Make

Use the generic HTTP / Webhook action; there is no dedicated Shipstar app and none is needed.

1. **Trigger**: new row in your CRM, new customer in Stripe, new signup in your auth provider — whatever marks someone as a user.
2. **Action**: HTTP request
   * Method `PUT`
   * URL `https://api.shipstar.ai/api/v1/email/lists/<list id>/recipients`
   * Headers `Authorization: Bearer <api token>`, `Content-Type: application/json`
   * Body (JSON):
     ```json theme={null}
     {"recipients": [{"email": "{{email}}", "name": "{{name}}", "external_id": "{{customer_id}}"}]}
     ```
3. For the churn side, a second Zap on your "customer deleted / subscription cancelled" trigger sends `DELETE https://api.shipstar.ai/api/v1/email/lists/<list id>/recipients/{{customer_id}}` with the same header.

Map the trigger's stable id to `external_id` so a later email change updates the same recipient.

## Recipe 4 — One-off CSV import or export

For an initial load from a spreadsheet, open the list in the dashboard (Destinations → Email) and use **Import CSV**. The importer looks for these headers, case-insensitively:

| Column | Accepted headers |
| - | - |
| Email (required) | `email`, `e-mail`, `email address` |
| Name | `name`, `full name`, or `first name` + `last name` |
| External id | `external_id`, `external id`, `user_id`, `id` |
| Anything else | stored in `attributes` (first 20 columns) |

A file with no header row and email addresses in the first column also works. You get a preview with the detected columns and counts before anything is sent, and the import runs through the same upsert as the API, so re-importing an updated file updates rather than duplicates.

**Export CSV** on the same page (or `GET .../recipients/export.csv` with a token) downloads every recipient with their status — a backup you can re-import later, or feed into another tool.

## Checking your work

* [`GET /email/lists/{id}`](/api-reference/mailing-lists/get-mailing-list) shows `active_count`, `removed_count` and `unsubscribed_count`; after a sync, `active_count` should match the number of users you pushed.
* [`GET /email/lists/{id}/recipients?q=<email>`](/api-reference/mailing-lists/list-recipients) finds one person and shows their status and the fields you wrote.
* In the dashboard, the list shows the same counts, and the **Sync via API** button prints ready-to-paste `curl` commands with the list id filled in.

## Related

* [Upsert Recipients](/api-reference/mailing-lists/upsert-recipients) — full matching rules and response fields
* [Remove Recipients](/api-reference/mailing-lists/remove-recipient) — single and batch forms
* [Authentication](/guides/authentication) — creating and using API tokens
* [Error Handling](/guides/error-handling) — rate-limit headers and retry guidance
