Skip to main content
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

Prerequisites

  • A subscription topic
  • The published templates that feed the digest

Pick a batching mechanism

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: A topic’s whole digest configuration is carried on the digest object of 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

1

Link the individual templates

In the (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.
2

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

Add schedules

Add at least one schedule (see 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.
4

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

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 , and every send through that template inherits it. To set the topic on one send instead, pass it on the message:

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

1

Confirm sends are held

Put a test user on a non-instant schedule for the topic, as described in , and send an individual notification. Confirm the log shows it DIGESTED rather than delivered.
2

Confirm the digest releases

Wait for the schedule, or release it early with the release call, and confirm the digest template delivers with the collected items.
3

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.

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:
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:
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:
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: 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. fires the digest now:
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.
shows what is queued and released: