# Weekly digest email: template and code

Source: https://www.courier.com/recipes/weekly-digest-email (manifest: https://www.courier.com/recipes/weekly-digest-email/manifest.json)

Send one weekly summary instead of a dozen separate pings.

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.

Tested 2026-10-19: passing.

## When to send a weekly digest email

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | Each time something happens | None, the event is held |  |
| 2 | Every Monday at 9:00, New York time | Email | Your weekly activity summary |

## Copy

### Weekly digest (email)

Subject: "Your weekly activity summary", "What happened this week", "Your week in review"

Here's what happened this week ({{digest.count}} in total). The latest:

Priya commented on Q4 launch plan

Marcus assigned you Fix the billing export

Dana mentioned you in Design review notes

Button: See all activity (https://app.acme.com/activity)

## The 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"
    }
  ]
}
```

## Create it in your workspace

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.

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts | COURIER_API_KEY=pk_... node --experimental-strip-types setup-digest.ts
```

setup.ts

```typescript
// 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

```typescript
// 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 }));
```

## Add each event as it happens

```typescript
// 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
```

## Turn it off, or send it now

```typescript
// 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,
    )
```

## Templates

setup.ts reads these from a templates/ folder next to it.

templates/weekly-digest-email.json

```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"
            }
          ]
        }
      ]
    }
  }
}
```

## Hand it to your agent

```text
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 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

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

## Related docs

- [Send to Digest node](https://www.courier.com/docs/journeys/nodes/digest)
- [Send a daily digest](https://www.courier.com/docs/guides/send-a-daily-digest)
- [Create a topic with a digest](https://www.courier.com/docs/api-reference/preference-topics/create-topic-in-section)
- [Release a recipient's digest](https://www.courier.com/docs/api-reference/digests/release-a-recipients-digest)
- [Build a preference center](https://www.courier.com/docs/guides/build-a-preference-center)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)

## Related recipes

- [Batched comment notifications](https://www.courier.com/recipes/comment-notification-batching): Turn a burst of comments into one notification instead of one per comment.
- [Usage limit alerts](https://www.courier.com/recipes/usage-limit-alert-email): Warn customers as they near a plan limit, and tell your own team when they hit it.
- [NPS survey email](https://www.courier.com/recipes/nps-survey-email): Ask engaged users for an NPS score with one click, and survey nobody more than once every 90 days.
