> ## 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`, the default import of the v7 Node SDK. 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 a daily digest

> Collapse many notifications into one scheduled message with a topic digest.

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 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 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 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="Journeys, Digests" />

Collapse a flood of individual notifications into one message a user will actually read.

Instead of ten emails about ten comments, a digest holds each event and delivers them together on the user's chosen schedule.

## What you will build

```mermaid theme={null}
flowchart LR
    A["Event fires"] --> B{"Digest schedule?"}
    B -->|Yes| C["Held as DIGESTED"]
    C -->|On schedule| D["One digest sent"]
    B -->|No| E["Sent now"]
```

## Prerequisites

* A subscription topic
* <Guide href="/docs/guides/build-a-preference-center">A preference center</Guide>
* The published templates that feed the digest
* <AppLink href="https://app.courier.com/settings/api-keys">A Courier API key</AppLink>

## Pick a batching mechanism

| Mechanism                  | What it does                                                                                                     | Use it when                                                        |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **Topic digest schedules** | Individual sends to a topic are held and released as one digest on the user's chosen schedule.                   | Users should control frequency (daily, weekly, custom) per topic.  |
| **Add to digest node**     | A journey node adds the event to a subscription topic's digest, released on the topic's schedule.                | You want to feed a digest from inside a journey.                   |
| **Batch node**             | A journey node collects events at a point in the flow and releases them together by item count or a wait window. | You need a time-window or count-based batch, not a topic schedule. |

The rest of this guide sets up a **topic digest**, the most common path.

## What is set where

An automated backend can drive most of a digest. One piece still cannot be set over the API, so know which is which before you script the setup:

| Piece                                              | Where it is set                           |
| -------------------------------------------------- | ----------------------------------------- |
| Subscription topic, and the templates linked to it | API or console                            |
| Linked digest template, schedules, categories      | API or console                            |
| The topic carried on a send                        | API                                       |
| A user's subscription status and channel routing   | API                                       |
| A user's digest schedule                           | The preference center, chosen by the user |

A topic's whole digest configuration is carried on the `digest` object of <Endpoint method="POST" path="/preferences/sections/{section_id}/topics" name="Create Topic in Section" href="/docs/api-reference/preference-topics/create-topic-in-section" /> and its replace and read counterparts, so the setup below can be scripted end to end. The one piece left to a person is which schedule an individual user sits on.

## Set up a topic digest

<Steps>
  <Step title="Link the individual templates">
    In the <AppLink href="https://app.courier.com/~/test/platform/preferences">preferences editor</AppLink> (**Platform → Preferences**), open the subscription topic that should be digestable. Link the individual notification templates that feed it, for example `task-assigned` and `comment-added`. When a user is on a digest schedule for the topic, these sends are held instead of delivered, and the log shows them as `DIGESTED`.
  </Step>

  <Step title="Design and link a digest template">
    Build a template that renders the collected events, then set it as the topic's **Linked Digest Template**. Users on a digest schedule receive this template instead of the individual notifications. Removing the linked template disables digesting for the topic.
  </Step>

  <Step title="Add schedules">
    Add at least one schedule (see [Schedules](#schedules) below). Include an **Instant** option so users can opt out of digesting. Each schedule you add appears as a choice in the preference center.
  </Step>

  <Step title="Group with categories (optional)">
    Categories separate event types within one digest, such as tasks against comments. Each category's **retain** rule decides which events appear: first 10, last 10, or 10 highest or lowest by a `sort_key`.
  </Step>
</Steps>

<Warning>
  Do not mix topic digest schedules and a journey Send to Digest for the same user and topic. The two write digest state differently, so combining them produces `INCOMPLETE_PROFILE_DATA` or `UNROUTABLE` errors. Pick one path per topic.
</Warning>

## Bind the send to the topic

A send is only a digest candidate when it carries a subscription topic. Nothing is held without one, whatever the topic is configured to do.

Link the topic on the template, as `notification.subscription.topic_id` on <Endpoint method="POST" path="/notifications" name="Create Notification Template" href="/docs/api-reference/templates/create-notification-template" />, and every send through that template inherits it. To set the topic on one send instead, pass it on the message:

```json theme={null}
{
  "message": {
    "preferences": { "subscription_topic_id": "pt_01kx4h2jdafq8bk9a26x0kvd1t" }
  }
}
```

## Which schedule a user is on

A user picks their schedule in the preference center, and that choice is what decides whether their sends are held. <Endpoint method="PUT" path="/users/{user_id}/preferences/{topic_id}" name="Update or create user Preferences for a Subscription Topic" href="/docs/api-reference/user-preferences/update-or-create-user-preferences-for-a-subscription-topic" /> sets subscription status and channel routing, and does not set the digest schedule.

A user who has never picked a schedule is not left out of digesting. They fall back to the topic's default schedule, or to the topic's first schedule when none is marked default.

That fallback is the usual reason a freshly scripted test never digests. Adding **Instant** as the default means every API-created user delivers immediately, and the log shows ordinary delivery rather than `DIGESTED`. To exercise the digest path, make a non-instant schedule the topic default, or set the test user's schedule in the preference center first.

## Verify

<Steps>
  <Step title="Confirm sends are held">
    Put a test user on a non-instant schedule for the topic, as described in <Doc href="#which-schedule-a-user-is-on">Which schedule a user is on</Doc>, and send an individual notification. Confirm the log shows it `DIGESTED` rather than delivered.
  </Step>

  <Step title="Confirm the digest releases">
    Wait for the schedule, or release it early with the [release call](#release-or-inspect-a-digest), and confirm the digest template delivers with the collected items.
  </Step>

  <Step title="Confirm Instant still delivers immediately">
    Switch the test user to the Instant schedule and send again. The message should deliver on its own instead of being held.
  </Step>
</Steps>

## Schedules

A schedule sets when digests release. The common recurrences are `daily`, `weekly`, and `monthly`. For anything in between, use a **custom** schedule with a per-weekday repeat, for example Tuesday and Thursday:

```json theme={null}
{
  "recurrence": "custom",
  "repeat": {
    "frequency": 1,
    "interval": "week",
    "on": {
      "tuesday": true,
      "thursday": true
    }
  },
  "timezone": "America/New_York"
}
```

Set `timezone` to an IANA zone on the schedule and Courier releases at that wall-clock time (DST aware). Without a `timezone`, the schedule time is read as UTC. The schedule timezone is the schedule's own, not each recipient's profile timezone.

**A custom schedule only supports a weekly interval with a per-weekday `on` map.** Stick to daily, weekly, monthly, or a weekly custom set of days. The unschedulable shapes are listed below, with the other things that go wrong without saying so.

## How digested items reach the template

When a digest releases, Courier passes the collected events as the message data, keyed by category, so your template iterates them:

```json theme={null}
{
  "task_updates": {
    "count": 25,
    "items": [
      { "task_title": "Review landing page copy", "assignee_name": "Sarah Bennett" }
    ]
  }
}
```

Each category carries a `count` (the total) and its `items` (chosen by the retain rule, up to the category's `limit`, which defaults to 10 and can be set from 1 to 100). An event whose top-level `data` key matches no category is not held. Courier delivers it immediately as an individual message. The `digest` key is used only when the topic has no categories.

Anything past the limit is discarded rather than carried into the next digest. A release consumes every event collected so far and renders only `limit` of them.

### Loop over the items

Iterate the items with a **list block** whose loop is set to the category path, not with a Handlebars `each` in a text block. Both render, but only the list block can be opened again in the template designer. A text block holding `{{#each}}` is drawn there as a row of broken variable chips, because the designer reads the loop keywords as variable names.

The loop path is evaluated as an expression against the message data, so it always starts with `data`, whatever the template's scope:

```json theme={null}
{
  "type": "list",
  "list_type": "unordered",
  "loop": "data.task_updates.items",
  "elements": [
    {
      "type": "list-item",
      "elements": [{ "type": "string", "content": "{{$.item.task_title}} for {{$.item.assignee_name}}" }]
    }
  ]
}
```

Inside the loop, each item is `$.item` and its position is `$.index`. Those are loop context, not data paths, so they are written the same way in every template.

The category total outside the loop is the one place the template's `scope` changes what you write:

| Template          | Loop path                 | Total                         |
| ----------------- | ------------------------- | ----------------------------- |
| `scope: "strict"` | `data.task_updates.items` | `{{data.task_updates.count}}` |
| default scope     | `data.task_updates.items` | `{{task_updates.count}}`      |

The wrong form renders as an empty string rather than an error, so it is worth setting `scope: "strict"` on a digest template and using `data.` for both. That is one convention instead of two, and it matches how the rest of a v2 template is written.

`count` is the number of events collected, which can be larger than the number of items you loop over. Items are capped by the category's `limit`, and the total still reports everything that arrived.

**Everything except `count` describes a single item.** A digest render has no slot for a value that belongs to the batch. So a fact that is identical on every event, such as a build number, still arrives once per item. That makes it tempting to read the first one with `data.task_updates.items.[0].build` and print it outside the loop.

Avoid that. It is positional. The moment the first collected event lacks the field, the value disappears from the whole digest, and there is no error and no empty placeholder to notice. Read those fields as `$.item.build` inside the loop, where each item speaks for itself.

## What fails quietly

None of these raise an error. That is what makes them worth knowing before you ship, because each one looks like it worked.

**A variable in the wrong form renders as an empty string.** Which form is right depends on the template's `scope`, as in the table above. A digest that arrives saying "you have  new items" is this, not a data problem.

**Events past a category's `limit` are discarded, not held for next time.** A release consumes everything collected and renders `limit` of them. Eight events under `limit: 3` deliver three and the other five are gone, while `count` still reports eight.

**A `{{#each}}` loop in a text block renders correctly and locks the template out of the designer.** The designer expresses iteration only as a list block. It reads the loop keywords as variable names and draws them as broken chips. Use a list block instead.

**A schedule Courier cannot express is skipped rather than rejected.** The topic saves, the schedule sits there, and nothing ever fires from it. These are the shapes that do it:

* "Every N weeks", meaning a `frequency` above 1
* a non-weekly interval
* the string form of `on`, used for monthly

**A batch-level value read positionally vanishes when the first item lacks it.** `items.[0].build` prints nothing rather than erroring, so a digest that silently drops a build number is this. Read it inside the loop as `$.item.build`.

**A recipient who never chose a schedule still digests.** They fall back to the topic default, so a test user you expected to be excluded is quietly included on whatever that default is.

## Release or inspect a digest

Schedules are part of the topic's `digest` object, so they are created with the topic. You can also release a topic's digest early and list its instances. <Endpoint method="POST" path="/digests/schedules/{schedule_id}/trigger" name="Release a digest early" href="/docs/api-reference/digests/release-a-digest-early" /> fires the digest now:

<Note>
  Schedule ids come back on the topic, as `digest.schedules[].schedule_id`, from the create, replace and single-topic read calls. There is no need to read them out of the console.

  Newer ids look like `sch_01m26nhsf7fzxr5kngwf8p0b2c` and drop straight into a URL. Ids created before that format look like `sch/{uuid}` and contain a literal slash, so those must be written as `%2F` in a raw URL. The SDKs escape either form for you. Existing ids are never migrated, so a topic can hold both.
</Note>

<CodeGroup>
  ```javascript Node.js theme={null}
  await client.digests.schedules.release('sch/a3726ea9-3453-465f-93ad-632061ba8f59');
  ```

  ```python Python theme={null}
  client.digests.schedules.release(
      "sch/a3726ea9-3453-465f-93ad-632061ba8f59",
  )
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.courier.com/digests/schedules/sch%2Fa3726ea9-3453-465f-93ad-632061ba8f59/trigger \
    -H "Authorization: Bearer $COURIER_API_KEY"
  ```

  ```ruby Ruby theme={null}
  result = courier.digests.schedules.release("sch/a3726ea9-3453-465f-93ad-632061ba8f59")
  ```

  ```go Go theme={null}
  err := client.Digests.Schedules.Release(context.TODO(), "sch/a3726ea9-3453-465f-93ad-632061ba8f59")
  ```

  ```java Java theme={null}
  client.digests().schedules().release("sch/a3726ea9-3453-465f-93ad-632061ba8f59");
  ```

  ```php PHP theme={null}
  $result = $client->digests->schedules->release('sch/a3726ea9-3453-465f-93ad-632061ba8f59');
  ```

  ```csharp C# theme={null}
  ScheduleReleaseParams parameters = new() { ScheduleID = "sch/a3726ea9-3453-465f-93ad-632061ba8f59" };

  await client.Digests.Schedules.Release(parameters);
  ```

  ```bash CLI theme={null}
  courier digests:schedules release \
    --api-key "$COURIER_API_KEY" \
    --schedule-id sch/a3726ea9-3453-465f-93ad-632061ba8f59
  ```

  ```text MCP theme={null}
  With Courier MCP, release the queued digest for my daily schedule now.
  ```
</CodeGroup>

<Endpoint method="GET" path="/digests/schedules/{schedule_id}/instances" name="List digest instances" href="/docs/api-reference/digests/list-digest-instances" /> shows what is queued and released:

<CodeGroup>
  ```javascript Node.js theme={null}
  const digestInstanceListResponse = await client.digests.schedules.listInstances('sch/a3726ea9-3453-465f-93ad-632061ba8f59');
  ```

  ```python Python theme={null}
  digest_instance_list_response = client.digests.schedules.list_instances(
      schedule_id="sch/a3726ea9-3453-465f-93ad-632061ba8f59",
  )
  ```

  ```bash cURL theme={null}
  curl -X GET https://api.courier.com/digests/schedules/sch%2Fa3726ea9-3453-465f-93ad-632061ba8f59/instances \
    -H "Authorization: Bearer $COURIER_API_KEY"
  ```

  ```ruby Ruby theme={null}
  digest_instance_list_response = courier.digests.schedules.list_instances("sch/a3726ea9-3453-465f-93ad-632061ba8f59")
  ```

  ```go Go theme={null}
  digestInstanceListResponse, err := client.Digests.Schedules.ListInstances(
  	context.TODO(),
  	"sch/a3726ea9-3453-465f-93ad-632061ba8f59",
  	courier.DigestScheduleListInstancesParams{},
  )
  ```

  ```java Java theme={null}
  DigestInstanceListResponse digestInstanceListResponse = client.digests().schedules().listInstances("sch/a3726ea9-3453-465f-93ad-632061ba8f59");
  ```

  ```php PHP theme={null}
  $digestInstanceListResponse = $client->digests->schedules->listInstances('sch/a3726ea9-3453-465f-93ad-632061ba8f59');
  ```

  ```csharp C# theme={null}
  ScheduleListInstancesParams parameters = new() { ScheduleID = "sch/a3726ea9-3453-465f-93ad-632061ba8f59" };

  var digestInstanceListResponse = await client.Digests.Schedules.ListInstances(parameters);
  ```

  ```bash CLI theme={null}
  courier digests:schedules list-instances \
    --api-key "$COURIER_API_KEY" \
    --schedule-id sch/a3726ea9-3453-465f-93ad-632061ba8f59
  ```

  ```text MCP theme={null}
  With Courier MCP, list the digest instances queued on my daily schedule.
  ```
</CodeGroup>
