Engagement
Weekly digest email: template and code
Send one weekly summary instead of a dozen separate pings.
Passing, tested 2026-10-19 · Email
How it works
When to send a weekly digest email
A weekly digest email collects a user's activity into one summary instead of an email for every event. Each event is added as it happens, and every Monday at 9:00 one email goes out with the total count and the latest 20 events. Anyone who turns the digest off stops getting it.
- ActivityYour app adds each event
- Each eventHeld for the digest
- MondayOne digest email
Stops early: Opting out of the topic stops the email, and your app can send someone's digest early.
Messages
What each step sends
Step 1: Each time something happens
Held for the digest.
Step 2: Every Monday at 9:00, New York time
One digest email.
Here's what happened this week (3 in total). The latest:
Priya commented on Q4 launch plan
Marcus assigned you Fix the billing export
Dana mentioned you in Design review notes
Other subject lines: "What happened this week", "Your week in review"
Build
Add it to your app
1. Create the journey
Run setup.ts once with your API key. It creates the templates and the journey, publishes it, and prints the journey ID you pass to step 2. Copy setup.ts, journey.json, and the templates folder from the full definition at the end of this section.
The second script reads the first one's output. It creates the subscription topic that holds the digest's template and its Monday 9:00 schedule, points the journey at it, and prints the journey, section, and topic IDs stop.ts needs.
Terminal
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts | COURIER_API_KEY=pk_... node --experimental-strip-types setup-digest.ts
2. Add each event as it happens
Node.js
// Add each activity event to the user's weekly digest, with the journey ID// setup.ts printed. Nothing sends now: Courier holds the event until the// topic's weekly schedule releases the digest.import Courier from "@trycourier/courier";const client = new Courier();export async function addToWeeklyDigest(journeyId: string,userId: string,event: { actorName: string; action: string; targetName: string }) {const { runId } = await client.journeys.invoke(journeyId, {user_id: userId,data: {actor_name: event.actorName,action: event.action,target_name: event.targetName,},});return runId;}
Python
# Add each activity event to the user's weekly digest, with the journey ID# setup.ts printed. Nothing sends now: Courier holds the event until the# topic's weekly schedule releases the digest.from courier import Courierclient = Courier()def add_to_weekly_digest(journey_id: str, user_id: str, event: dict) -> str:response = client.journeys.invoke(template_id=journey_id,user_id=user_id,data={"actor_name": event["actor_name"],"action": event["action"],"target_name": event["target_name"],},)return response.run_id
3. Turn it off, or send it now
Node.js
// With the topic and section IDs setup-digest.ts printed: turn the weekly// email off for one user, or send their digest now instead of on Monday.import Courier from "@trycourier/courier";const client = new Courier();export async function stopWeeklyDigest(topicId: string, userId: string) {await client.users.preferences.updateOrCreateTopic(topicId, {user_id: userId,topic: { status: "OPTED_OUT" },});}export async function sendWeeklyDigestNow(sectionId: string,topicId: string,userId: string) {await client.workspacePreferences.topics.releaseDigest(topicId, {section_id: sectionId,user_id: userId,});}
Python
# With the topic and section IDs setup-digest.ts printed: turn the weekly# email off for one user, or send their digest now instead of on Monday.from courier import Courierclient = Courier()def stop_weekly_digest(topic_id: str, user_id: str) -> None:client.users.preferences.update_or_create_topic(topic_id,user_id=user_id,topic={"status": "OPTED_OUT"},)def send_weekly_digest_now(section_id: str, topic_id: str, user_id: str) -> None:client.workspace_preferences.topics.release_digest(topic_id,section_id=section_id,user_id=user_id,)
Full definition: journey.json, setup.ts, setup-digest.ts, and the 1 template
The journey has one working node, Send to Digest, and it only collects. The digest's template, weekly schedule, and event limit live on a subscription topic.
When the digest releases, the template gets digest.count (every event this week) and digest.items (the latest 20). Change the button URL in the template to your own activity page.
journey.json
{"name": "Weekly activity digest","nodes": [{"type": "trigger","trigger_type": "api-invoke"},{"type": "add-to-digest","subscription_topic_id": "set-by-setup-digest"},{"type": "exit"}]}
setup.ts
// Creates this recipe's journey from journey.json and the templates/ folder,// then publishes it. Run it once per workspace://// COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts//// It prints the journey ID and the template IDs it created.import Courier from "@trycourier/courier";import { readFileSync, readdirSync } from "node:fs";const client = new Courier();const here = new URL(".", import.meta.url);const readJson = (path: string) =>JSON.parse(readFileSync(new URL(path, here), "utf8"));const spec = readJson("journey.json");// 1. Create the journey, then the templates its send nodes use.const journey = await client.journeys.create({name: spec.name,nodes: [spec.nodes[0], spec.nodes.at(-1)],});const templateIds: Record<string, string> = {};for (const file of readdirSync(new URL("templates/", here))) {const key = file.replace(/\.json$/, "");const template = await client.journeys.templates.create(journey.id,readJson(`templates/${file}`));await client.journeys.templates.publish(template.id, {templateId: journey.id,});templateIds[key] = template.id;}// 2. Save the nodes. In journey.json, "@<template>" stands for the ID of the// template with that file name. Node IDs must be unique across a workspace,// so any node ID journey.json names (used by "send_status.<id>" conditions)// gets this journey's ID added.const nodeIds = [...JSON.stringify(spec.nodes).matchAll(/"id":"([\w-]+)"/g),].map((m) => m[1]);let nodes = JSON.stringify(spec.nodes).replace(/"@([\w-]+)"/g, (_, key) =>JSON.stringify(templateIds[key]));for (const id of nodeIds)nodes = nodes.replace(new RegExp(`(?<=[".])${id}(?=")`, "g"),`${id}-${journey.id}`);await client.journeys.replace(journey.id, {name: spec.name,cancelation_token: spec.cancelation_token,nodes: JSON.parse(nodes),});// 3. Publish, so new runs use this version.await client.journeys.publish(journey.id);console.log(JSON.stringify({ journeyId: journey.id, templateIds }));
setup-digest.ts
// Creates the weekly schedule behind this recipe's Send to Digest node: a// subscription topic whose digest renders with the journey's weekly digest// template and releases every Monday at 9:00. Then it points the journey at// the topic and publishes it. It reads the output of setup.ts from stdin, so// pipe the two together://// COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts | COURIER_API_KEY=pk_... node --experimental-strip-types setup-digest.ts//// Set COURIER_PREFERENCE_SECTION_ID to add the topic to a preference section// you already have. It prints the journey, section, and topic IDs, which// stop.ts needs.import Courier from "@trycourier/courier";import { readFileSync } from "node:fs";const client = new Courier();// 1. The journey and digest template that setup.ts just created.const { journeyId, templateIds } = JSON.parse(readFileSync(0, "utf8"));const templateId = templateIds?.["weekly-digest-email"];if (!journeyId || !templateId)throw new Error("Pipe the output of setup.ts into this script");// 2. A topic with a digest: the template, a weekly schedule, and the latest// 20 events. Users who never pick a schedule get the default one.const sectionId =process.env.COURIER_PREFERENCE_SECTION_ID ??(await client.workspacePreferences.create({ name: "Activity" })).id;const topic = await client.workspacePreferences.topics.create(sectionId, {name: "Weekly activity summary",default_status: "OPTED_IN",routing_options: ["email"],digest: {template_id: templateId,schedules: [{frequency: "weekly",day_of_week: "monday",time: "09:00",timezone: "America/New_York",is_default: true,},],categories: [{ category_key: "digest", retain: "LAST", limit: 20 }],},});// 3. Point the journey's Send to Digest node at the topic, then publish.const journey = await client.journeys.retrieve(journeyId);await client.journeys.replace(journeyId, {name: journey.name,nodes: journey.nodes.map((node: any) =>node.type === "add-to-digest"? { ...node, subscription_topic_id: topic.id }: node),});await client.journeys.publish(journeyId);console.log(JSON.stringify({ journeyId, sectionId, topicId: topic.id }));
templates/weekly-digest-email.json
{"channel": "email","notification": {"name": "Weekly digest (email)","tags": ["recipe", "weekly-digest-email"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Your weekly activity summary"},{"type": "text","content": "Here's what happened this week ({{digest.count}} in total). The latest:"},{"type": "text","content": "{{$.item.actor_name}} {{$.item.action}} {{$.item.target_name}}","loop": "data.digest.items"},{"type": "action","content": "See all activity","href": "https://app.acme.com/activity"}]}]}}}
Agents
Hand it to your agent
Give this prompt to Claude Code, Cursor, or Codex with the Courier skill installed. Agents can also read the markdown version and the manifest.
Data
Data fields
| Field | Type | Example | Note |
|---|---|---|---|
| actor_name | string | Sam | Who did it |
| action | string | commented on | |
| target_name | string | Q4 plan | |
| digest.count | number | 3 | Set by Courier when the digest releases |
FAQ
Frequently asked questions
How do I send a weekly digest email instead of individual notifications?
Send each event to a Send to Digest node instead of a send node. Courier holds the events for each user on a subscription topic and sends one email on the topic's schedule, here every Monday at 9:00.
Where do I set up the digest schedule?
In the Preferences Editor in the Courier dashboard: the topic, its schedule, and the link to the digest template all live there. To do it in code instead, the digest setup script creates all of it over the API.
How do I test a digest without waiting a week?
Release one user's digest early with the sendWeeklyDigestNow function in the stop code. It sends whatever has been collected so far, and it's safe to call again.
What happens when someone opts out of the digest?
Their events are still collected, but the digest isn't delivered: Courier marks it undeliverable because they unsubscribed from the topic.
Recipes
Related recipes
Turn a burst of comments into one notification instead of one per comment.
Warn customers as they near a plan limit, and tell your own team when they hit it.
Ask engaged users for an NPS score with one click, and survey nobody more than once every 90 days.
Build this in your workspace
Run setup.ts against a free account and the journey is live in a minute. Prefer no code? Rebuild it in the Journeys editor.