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

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message` from the Node SDK (`@trycourier/courier` v7 and later, where the client is the default import). Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Send in bulk

> Send to thousands of recipients you supply with a bulk job you can review before it runs.

export const Tags = ({items}) => {
  const routes = {
    Email: "/integrations/email/overview",
    SMS: "/integrations/sms/overview",
    Push: "/integrations/push/overview",
    Inbox: "/in-app/overview",
    Chat: "/integrations/direct-message/overview",
    Templates: "/design/templates/overview",
    Variables: "/design/templates/variables",
    Elemental: "/design/elemental/overview",
    Brands: "/design/brands",
    Translations: "/design/elemental/locales",
    Routing: "/send/routing",
    Preferences: "/recipients/preferences/overview",
    Journeys: "/journeys/overview",
    Broadcasts: "/broadcasts/overview",
    Tenants: "/tenants/overview",
    Logs: "/monitor/overview",
    Webhooks: "/monitor/webhooks/outbound",
    Lists: "/recipients/lists-and-audiences/overview",
    Users: "/recipients/overview",
    Digests: "/journeys/nodes/digest",
    Environments: "/workspaces/overview",
    MCP: "/resources/mcp"
  };
  const icons = {
    Email: "envelope",
    SMS: "comment",
    Push: "mobile",
    Inbox: "inbox",
    Chat: "comments",
    Templates: "pen-ruler",
    Variables: "pen-ruler",
    Elemental: "pen-ruler",
    Brands: "pen-ruler",
    Translations: "pen-ruler",
    Routing: "paper-plane",
    Preferences: "users",
    Journeys: "route",
    Broadcasts: "bullhorn",
    Tenants: "building",
    Logs: "chart-simple",
    Webhooks: "chart-simple",
    Lists: "users",
    Users: "users",
    Digests: "route",
    Environments: "briefcase",
    MCP: "toolbox"
  };
  const base = "https://d3gk2c5xim1je2.cloudfront.net/fontawesome/v7.2.0/regular/";
  const names = String(items || "").split(",").map(entry => entry.trim()).filter(Boolean);
  return <div className="cx-tags">
      {names.map(name => {
    const href = routes[name];
    const icon = icons[name];
    const url = icon ? "url(" + base + icon + ".svg)" : null;
    const style = url ? {
      "--cx-tag-icon": url
    } : null;
    if (!href) {
      return <span className="cx-tag" data-icon={icon} style={style} key={name}>
              {name}
            </span>;
    }
    return <a className="cx-tag" data-icon={icon} style={style} href={href} key={name}>
            {name}
          </a>;
  })}
    </div>;
};

export const Endpoint = ({method, path, name, href, children, bare}) => {
  const verb = String(method || "").toUpperCase();
  const title = verb + " " + path;
  const label = children || name || path;
  if (bare) {
    return href ? <a href={href}><code>{title}</code></a> : <code>{title}</code>;
  }
  if (!href) {
    return <span className="cx-endpoint" data-method={verb} title={title}>
        <span className="cx-endpoint-label">{label}</span>
        <span className="cx-endpoint-method">{verb}</span>
      </span>;
  }
  return <a className="cx-endpoint" data-method={verb} href={href} title={title}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">{verb}</span>
    </a>;
};

export const AppLink = ({href, children, name, bare}) => {
  const label = children || name || "Open in Courier";
  if (bare) {
    return <a href={href} target="_blank" rel="noreferrer">{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="app" href={href} target="_blank" rel="noreferrer">
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method" aria-hidden="true">↗</span>
    </a>;
};

export const Guide = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="guide" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">GUIDE</span>
    </a>;
};

export const Doc = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="doc" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">DOC</span>
    </a>;
};

<Tags items="Users, Templates" />

Send one notification to thousands of recipients you supply yourself, with a job you can abandon before anything goes out.

Looping over Send API calls has no undo. A bulk job does. You build it across as many ingestion calls as you need, and nothing sends until you run it. An ingestion that fails halfway has delivered nothing, so abandon that job and build a fresh one.

## What you will build

```mermaid theme={null}
flowchart LR
    A["Create the job"] --> B["Ingest recipients"]
    B --> C{"Everyone in?"}
    C -->|Yes| D["Run the job"]
    D --> E["Poll for progress"]
    C -->|No| B
```

## Prerequisites

* <Doc href="/docs/design/templates/overview">A published template, or an event ID mapped to one</Doc>
* Your deduplicated recipient list, with an email or phone for each
* <AppLink href="https://app.courier.com/settings/api-keys">A Courier API key</AppLink>

## Run a bulk job

**Create** the job with the notification and any shared data, **ingest** recipients into it, then **run** it once and poll for progress. Ingestion is open-ended and the job does not expire while you fill it. Running is a one-way door.

<Steps>
  <Step title="Create the job">
    <Endpoint method="POST" path="/bulk" name="Create a bulk job" href="/docs/api-reference/bulk/create-a-bulk-job" /> defines the job with an `event` and any global data that applies to everyone. The required `event` is either a <Doc href="/docs/design/templates/overview">notification ID</Doc> or a custom event ID mapped to a notification.

    <CodeGroup>
      ```javascript Node.js highlight={3} theme={null}
      const { jobId } = await client.bulk.createJob({
        message: {
          event: 'welcome-email',
          data: { company_name: 'Acme Corp' },
        },
      });
      ```

      ```python Python highlight={3} theme={null}
      response = client.bulk.create_job(
          message={
              "event": "welcome-email",
              "data": {"company_name": "Acme Corp"},
          }
      )
      ```

      ```bash cURL highlight={6} theme={null}
      curl -X POST https://api.courier.com/bulk \
        -H "Authorization: Bearer $COURIER_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "message": {
            "event": "welcome-email",
            "data": { "company_name": "Acme Corp" }
          }
        }'
      ```

      ```ruby Ruby highlight={2} theme={null}
      response = courier.bulk.create_job(
        message: { event: "welcome-email", data: { company_name: "Acme Corp" } }
      )
      ```

      ```go Go highlight={3} theme={null}
      response, err := client.Bulk.NewJob(context.TODO(), courier.BulkNewJobParams{
      	Message: courier.InboundBulkMessageParam{
      		Event: "welcome-email",
      		Data: map[string]any{
      			"company_name": "Acme Corp",
      		},
      	},
      })
      ```

      ```java Java highlight={3} theme={null}
      BulkCreateJobParams params = BulkCreateJobParams.builder()
          .message(InboundBulkMessage.builder()
              .event("welcome-email")
              .data(InboundBulkMessage.Data.builder()
                  .putAdditionalProperty("company_name", JsonValue.from("Acme Corp"))
                  .build())
              .build())
          .build();
      BulkCreateJobResponse response = client.bulk().createJob(params);
      ```

      ```php PHP highlight={3} theme={null}
      $response = $client->bulk->createJob(
        message: [
          'event' => 'welcome-email',
          'data' => ['company_name' => 'Acme Corp'],
        ],
      );
      ```

      ```csharp C# highlight={5} theme={null}
      BulkCreateJobParams parameters = new()
      {
          Message = new()
          {
              Event = "welcome-email",
              Data = new Dictionary<string, JsonElement>()
              {
                  { "company_name", JsonSerializer.SerializeToElement("Acme Corp") }
              },
          },
      };

      var response = await client.Bulk.CreateJob(parameters);
      ```

      ```bash CLI highlight={3} theme={null}
      courier bulk create-job \
        --api-key "$COURIER_API_KEY" \
        --message '{"event":"welcome-email","data":{"company_name":"Acme Corp"}}'
      ```

      ```text MCP theme={null}
      With Courier MCP, create a bulk job for my welcome-email event with Acme Corp as global data.
      ```
    </CodeGroup>

    Returns `201 Created` with the job ID you use for every call that follows:

    ```json theme={null}
    { "jobId": "1-61e9dd53-b5bb6c863b7ffbe83ad4b28d" }
    ```

    You can also pass `brand`, `locale`, and `override`. To send something other than the notification attached to the event, add `template`. `event` stays required.

    <Tip>
      **Make a retry safe.**<br />
      Send an `Idempotency-Key` header and a retry replays the original result. A repeated create returns the first `jobId` instead of opening a second job.
    </Tip>
  </Step>

  <Step title="Ingest your recipients">
    <Endpoint method="POST" path="/bulk/{job_id}" name="Add users" href="/docs/api-reference/bulk/add-users" /> adds recipients to the job. Identify each one in `to`, and put their personal variables in `data`, which merges into the job's global `message.data`.

    <CodeGroup>
      ```javascript Node.js highlight={6,11} theme={null}
      await client.bulk.addUsers('JOB_ID', {
        users: [
          {
            to: { user_id: 'user_123' },
            profile: { email: 'sarah@acme-corp.com' },
            data: { name: 'Sarah Bennett' },
          },
          {
            to: { user_id: 'user_456' },
            profile: { email: 'kai@acme-corp.com' },
            data: { name: 'Kai Turner' },
          },
        ],
      });
      ```

      ```python Python highlight={7,12} theme={null}
      client.bulk.add_users(
          job_id="JOB_ID",
          users=[
              {
                  "to": {"user_id": "user_123"},
                  "profile": {"email": "sarah@acme-corp.com"},
                  "data": {"name": "Sarah Bennett"},
              },
              {
                  "to": {"user_id": "user_456"},
                  "profile": {"email": "kai@acme-corp.com"},
                  "data": {"name": "Kai Turner"},
              },
          ],
      )
      ```

      ```bash cURL highlight={9,14} theme={null}
      curl -X POST https://api.courier.com/bulk/JOB_ID \
        -H "Authorization: Bearer $COURIER_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "users": [
            {
              "to": { "user_id": "user_123" },
              "profile": { "email": "sarah@acme-corp.com" },
              "data": { "name": "Sarah Bennett" }
            },
            {
              "to": { "user_id": "user_456" },
              "profile": { "email": "kai@acme-corp.com" },
              "data": { "name": "Kai Turner" }
            }
          ]
        }'
      ```

      ```ruby Ruby highlight={6,9} theme={null}
      result = courier.bulk.add_users(
        "JOB_ID",
        users: [
          { to: { user_id: "user_123" },
            profile: { email: "sarah@acme-corp.com" },
            data: { name: "Sarah Bennett" } },
          { to: { user_id: "user_456" },
            profile: { email: "kai@acme-corp.com" },
            data: { name: "Kai Turner" } },
        ]
      )
      ```

      ```go Go highlight={8,12} theme={null}
      err := client.Bulk.AddUsers(
      	context.TODO(),
      	"JOB_ID",
      	courier.BulkAddUsersParams{
      		Users: []courier.InboundBulkMessageUserParam{{
      			To:      shared.UserRecipientParam{UserID: courier.String("user_123")},
      			Profile: map[string]any{"email": "sarah@acme-corp.com"},
      			Data:    map[string]any{"name": "Sarah Bennett"},
      		}, {
      			To:      shared.UserRecipientParam{UserID: courier.String("user_456")},
      			Profile: map[string]any{"email": "kai@acme-corp.com"},
      			Data:    map[string]any{"name": "Kai Turner"},
      		}},
      	},
      )
      ```

      ```java Java highlight={8,15} theme={null}
      BulkAddUsersParams params = BulkAddUsersParams.builder()
          .jobId("JOB_ID")
          .addUser(InboundBulkMessageUser.builder()
              .to(UserRecipient.builder().userId("user_123").build())
              .profile(InboundBulkMessageUser.Profile.builder()
                  .putAdditionalProperty("email", JsonValue.from("sarah@acme-corp.com"))
                  .build())
              .data(JsonValue.from(Map.of("name", "Sarah Bennett")))
              .build())
          .addUser(InboundBulkMessageUser.builder()
              .to(UserRecipient.builder().userId("user_456").build())
              .profile(InboundBulkMessageUser.Profile.builder()
                  .putAdditionalProperty("email", JsonValue.from("kai@acme-corp.com"))
                  .build())
              .data(JsonValue.from(Map.of("name", "Kai Turner")))
              .build())
          .build();
      client.bulk().addUsers(params);
      ```

      ```php PHP highlight={7,12} theme={null}
      $result = $client->bulk->addUsers(
        'JOB_ID',
        users: [
          [
            'to' => ['userID' => 'user_123'],
            'profile' => ['email' => 'sarah@acme-corp.com'],
            'data' => ['name' => 'Sarah Bennett'],
          ],
          [
            'to' => ['userID' => 'user_456'],
            'profile' => ['email' => 'kai@acme-corp.com'],
            'data' => ['name' => 'Kai Turner'],
          ],
        ],
      );
      ```

      ```csharp C# highlight={13,22} theme={null}
      BulkAddUsersParams parameters = new()
      {
          JobID = "JOB_ID",
          Users =
          [
              new()
              {
                  To = new() { UserID = "user_123" },
                  Profile = new Dictionary<string, JsonElement>()
                  {
                      { "email", JsonSerializer.SerializeToElement("sarah@acme-corp.com") }
                  },
                  Data = JsonSerializer.SerializeToElement(new { name = "Sarah Bennett" }),
              },
              new()
              {
                  To = new() { UserID = "user_456" },
                  Profile = new Dictionary<string, JsonElement>()
                  {
                      { "email", JsonSerializer.SerializeToElement("kai@acme-corp.com") }
                  },
                  Data = JsonSerializer.SerializeToElement(new { name = "Kai Turner" }),
              },
          ],
      };

      await client.Bulk.AddUsers(parameters);
      ```

      ```bash CLI highlight={4-5} theme={null}
      courier bulk add-users \
        --api-key "$COURIER_API_KEY" \
        --job-id JOB_ID \
        --user '{"to":{"user_id":"user_123"},"profile":{"email":"sarah@acme-corp.com"},"data":{"name":"Sarah Bennett"}}' \
        --user '{"to":{"user_id":"user_456"},"profile":{"email":"kai@acme-corp.com"},"data":{"name":"Kai Turner"}}'
      ```

      ```text MCP theme={null}
      With Courier MCP, add these recipients to my bulk job, each with their own name variable.
      ```
    </CodeGroup>

    Returns `200 OK`, where `total` is the running count for the whole job rather than the current call:

    ```json theme={null}
    {
      "errors": [],
      "total": 2
    }
    ```

    <Warning>
      **Email jobs need each address in `profile.email`.**<br />
      Provider routing ignores `to.email`, so a job built without `profile.email` runs clean and delivers nothing.
    </Warning>

    Each recipient accepts four fields:

    * `to.user_id`, who they are
    * `profile`, inline contact data
    * `data`, per-recipient variables
    * `preferences`, per-recipient overrides

    Call this endpoint as often as you need, keeping each batch to around 1000 recipients. Larger batches can fail with a `502` instead of a validation error, so split big lists across several calls.

    <Warning>
      **Ingestion does not deduplicate.**<br />
      The same `user_id` sent twice becomes two entries and two messages, so clean your list before you ingest it.
    </Warning>
  </Step>

  <Step title="Run the job">
    Once everyone is in, <Endpoint method="POST" path="/bulk/{job_id}/run" name="Run a job" href="/docs/api-reference/bulk/run-a-job" /> triggers the send. Courier fans out and delivers to each recipient.

    <CodeGroup>
      ```javascript Node.js theme={null}
      await client.bulk.runJob('JOB_ID');
      ```

      ```python Python theme={null}
      client.bulk.run_job(job_id="JOB_ID")
      ```

      ```bash cURL theme={null}
      curl -X POST https://api.courier.com/bulk/JOB_ID/run \
        -H "Authorization: Bearer $COURIER_API_KEY"
      ```

      ```ruby Ruby theme={null}
      courier.bulk.run_job("JOB_ID")
      ```

      ```go Go theme={null}
      err := client.Bulk.RunJob(context.TODO(), "JOB_ID")
      ```

      ```java Java theme={null}
      client.bulk().runJob("JOB_ID");
      ```

      ```php PHP theme={null}
      $client->bulk->runJob('JOB_ID');
      ```

      ```csharp C# theme={null}
      await client.Bulk.RunJob(new BulkRunJobParams { JobID = "JOB_ID" });
      ```

      ```bash CLI theme={null}
      courier bulk run-job \
        --api-key "$COURIER_API_KEY" \
        --job-id JOB_ID
      ```

      ```text MCP theme={null}
      With Courier MCP, run my bulk job.
      ```
    </CodeGroup>

    Returns `202 Accepted` with an empty body, and the job starts processing in the background.

    <Warning>
      **A job runs once.**<br />
      A second attempt returns `400 BulkJobDuplicateInvocationError`, so reaching more recipients means creating a new job.
    </Warning>
  </Step>

  <Step title="Track progress">
    <Endpoint method="GET" path="/bulk/{job_id}" name="Get a job" href="/docs/api-reference/bulk/get-a-job" /> returns the job's counts and overall status.

    <CodeGroup>
      ```javascript Node.js theme={null}
      const { job } = await client.bulk.retrieveJob('JOB_ID');

      console.log(job.status, `${job.enqueued}/${job.received} enqueued`);
      ```

      ```python Python theme={null}
      response = client.bulk.retrieve_job(job_id="JOB_ID")

      print(response.job.status, response.job.enqueued, response.job.received)
      ```

      ```bash cURL theme={null}
      curl https://api.courier.com/bulk/JOB_ID \
        -H "Authorization: Bearer $COURIER_API_KEY"
      ```

      ```ruby Ruby theme={null}
      response = courier.bulk.retrieve_job("JOB_ID")

      puts(response.job.status, response.job.enqueued, response.job.received)
      ```

      ```go Go theme={null}
      response, err := client.Bulk.GetJob(context.TODO(), "JOB_ID")
      if err != nil {
      	panic(err.Error())
      }

      fmt.Printf("%+v\n", response.Job)
      ```

      ```java Java theme={null}
      BulkRetrieveJobResponse response = client.bulk().retrieveJob("JOB_ID");
      ```

      ```php PHP theme={null}
      $response = $client->bulk->retrieveJob('JOB_ID');

      var_dump($response);
      ```

      ```csharp C# theme={null}
      var response = await client.Bulk.RetrieveJob(new BulkRetrieveJobParams { JobID = "JOB_ID" });

      Console.WriteLine(response);
      ```

      ```bash CLI theme={null}
      courier bulk retrieve-job \
        --api-key "$COURIER_API_KEY" \
        --job-id JOB_ID
      ```

      ```text MCP theme={null}
      With Courier MCP, show me the status and counts for my bulk job.
      ```
    </CodeGroup>

    ```json theme={null}
    {
      "job": {
        "definition": {
          "event": "welcome-email"
        },
        "enqueued": 2,
        "failures": 0,
        "received": 2,
        "status": "COMPLETED"
      }
    }
    ```

    * `received`: recipients ingested.
    * `enqueued`: messages that reached the delivery pipeline.
    * `failures`: errors hit while processing.
    * `status`: `CREATED`, `PROCESSING`, `COMPLETED`, or `ERROR`.

    <Note>
      **`COMPLETED` and `ERROR` are both terminal.**<br />
      Watch for either, or a failed job leaves you polling forever.
    </Note>
  </Step>
</Steps>

## Verify

<Steps>
  <Step title="Confirm the counts line up">
    Poll the job until it reaches `COMPLETED`, then check that `enqueued` matches `received` and `failures` is zero.
  </Step>

  <Step title="Spot-check a recipient">
    Page through the job's users with `client.bulk.listUsers`. Leave `cursor` off the first request, then keep passing `paging.cursor` back while `paging.more` is `true`. Each entry carries a `recipient`, a `status` of `PENDING`, `ENQUEUED`, or `ERROR`, and a `messageId` once enqueued.
  </Step>

  <Step title="Trace one message end to end">
    A `messageId` is the same ID a regular send returns, so look one up in <Doc href="/docs/monitor/overview">message logs</Doc> and confirm it delivered.
  </Step>
</Steps>

To skip polling, <Guide href="/docs/guides/track-delivery">track delivery</Guide> with an outbound webhook and handle `message:updated`, which fires on every status change for every message the job produced.

## Troubleshooting

| Symptom                                 | Cause                      | Fix                                  |
| --------------------------------------- | -------------------------- | ------------------------------------ |
| Job completes, no email arrives         | Address was in `to.email`  | Move it to `profile.email`           |
| `400 The 'event' parameter is required` | Create body has no `event` | Add a notification ID or event ID    |
| `400 BulkJobDuplicateInvocationError`   | Job already ran            | Create a new job for more recipients |
| `502` on ingest                         | Batch too large            | Send around 1000 recipients per call |
| Status never reaches `COMPLETED`        | Job ended in `ERROR`       | Treat `ERROR` as terminal too        |
| Someone got the same notification twice | Ingested twice             | Deduplicate before ingesting         |
