What you’ll 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
Every piece of a digest can be set over the API, so a backend can script the whole 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.
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.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, asnotification.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. To set it from your backend, passdigest_schedule_id on . The user’s next send follows that schedule:
digest_schedule_id out to keep the user’s current choice, or send null to put them back on the topic default. An id that isn’t a schedule on the topic returns 400. Reads of the user’s preferences return digest_schedule_id once they have one.
A user who has never picked a schedule isn’t 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 put the test user on one with digest_schedule_id 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. Each entry indigest.schedules has a frequency: instant, daily, weekdays, weekly, custom_days, or monthly. Every frequency except instant needs a 24-hour time. weekly also needs day_of_week, custom_days needs days_of_week, and monthly needs day_of_month. For Tuesday and Thursday:
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 missing field or an unknown frequency returns 400 and leaves the topic’s schedules as they were. Mark the schedule users start on with is_default: true rather than relying on its position.
A stored schedule that reads back with a schedule_id and no frequency doesn’t fire. Replace it with one of the six frequencies above.
The schedules array replaces what is stored. Pass a schedule’s schedule_id to keep it, and any schedule you leave out is deleted.
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:data object from one send, so it repeats the category key: an item’s title is task_updates.task_title, not task_title. 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 isn’t 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 Handlebarseach 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:
$.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. Set
scope: "strict" on a digest template and use data. for both: one convention instead of two, matching 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].task_updates.build and print it outside the loop.
Read it inside the loop as $.item.task_updates.build instead. The [0] form reads only the first event, so the value drops out of the whole digest whenever that event lacks the field.
Release or inspect a digest
Schedules are part of the topic’sdigest object, so they are created with the topic. You can also release a topic’s digest early, release one user’s digest, and list a schedule’s instances. sends one user’s held digest now, the quickest way to see what a digest renders. Pass their user_id in the body, plus tenant_id if they were sent to as part of a tenant. To release everyone on a schedule, 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.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.