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.

  1. Activity
    Your app adds each event
  2. Each event
    Held for the digest
  3. Monday
    One 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.

Email

From: Acme
Your weekly activity summary

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

See all activity

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 Courier
client = 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 Courier
client = 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.

Add a weekly activity digest to this app, sent through Courier: collect each activity event for a user and send one summary email every Monday instead of an email per event. Follow the steps at https://www.courier.com/recipes/weekly-digest-email.md and use the Courier skill for SDK details. 1. Create the journey and its digest template in my Courier workspace by running the setup command from that page, with COURIER_API_KEY from our environment. Save the IDs it prints as COURIER_DIGEST_JOURNEY_ID, COURIER_DIGEST_SECTION_ID, and COURIER_DIGEST_TOPIC_ID. 2. Find where this codebase records activity a user should hear about, like comments, assignments, and mentions, and add each event to the digest there. Pass the fields in the Data fields table from our own data. 3. Find where a user turns off email summaries and opt them out with stopWeeklyDigest. 4. Match this codebase's language, file layout, and error handling. Never hardcode the API key. 5. When you're done, add three events for a test user, release their digest with sendWeeklyDigestNow, and show me the email it sent.

Data

Data fields

FieldTypeExampleNote
actor_namestringSamWho did it
actionstringcommented on
target_namestringQ4 plan
digest.countnumber3Set 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.

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.