# Comment notification batching: template and code

Source: https://www.courier.com/recipes/comment-notification-batching (manifest: https://www.courier.com/recipes/comment-notification-batching/manifest.json)

Turn a burst of comments into one notification instead of one per comment.

Comment notification batching turns a burst of comments into one notification instead of one per comment. When the comments stop for 10 minutes, or an hour after the first one, or at 100 comments, it sends one Inbox message and one email with the count and the latest five. Reading the comments first cancels it.

Tested 2026-10-19: passing.

## When to send comment notifications

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | 10 quiet minutes after the last comment, 1 hour after the first, or at 100 comments | Inbox, Email | New comments ({{batch.count}}) |

## Copy

### New comments (Inbox)

Subject: "New comments ({{batch.count}})"

Priya on Q4 launch plan: "Can we move the beta to the 14th?"

Marcus on Q4 launch plan: "Works for me."

Dana on Pricing page copy: "Left two edits on the hero."

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

### New comments (email)

Subject: "New comments ({{batch.count}})", "New replies on your threads ({{batch.count}})", "{{profile.first_name}}, you have new comments"

Hi {{default profile.first_name "there"}}, here are the latest comments on threads you follow:

Priya on Q4 launch plan: "Can we move the beta to the 14th?"

Marcus on Q4 launch plan: "Works for me."

Dana on Pricing page copy: "Left two edits on the hero."

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

## The journey

```json
{
  "name": "Comment notification batching",
  "cancelation_token": "comments-{{recipient}}",
  "nodes": [
    {
      "type": "trigger",
      "trigger_type": "api-invoke"
    },
    {
      "type": "batch",
      "scope": "user",
      "wait_period": "PT10M",
      "max_wait_period": "PT1H",
      "max_items": 100,
      "retain": {
        "type": "last",
        "count": 5
      }
    },
    {
      "type": "send",
      "channel": "inbox",
      "message": {
        "template": "@comment-batch-inbox"
      }
    },
    {
      "type": "send",
      "channel": "email",
      "message": {
        "template": "@comment-batch-email"
      }
    },
    {
      "type": "exit"
    }
  ]
}
```

## Create it in your workspace

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.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 }));
```

## Start it on every new comment

```typescript
// Start the journey on every new comment, once per person who should hear
// about it, with the journey ID setup.ts printed. The first comment opens a
// batch for that person; the rest join it and end there. The email reads the
// person's first name from their Courier profile.
import Courier from "@trycourier/courier";

const client = new Courier();

export async function notifyComment(
  journeyId: string,
  recipientId: string,
  comment: { authorName: string; text: string; threadTitle: string }
) {
  const { runId } = await client.journeys.invoke(journeyId, {
    user_id: recipientId,
    data: {
      author_name: comment.authorName,
      comment_text: comment.text,
      thread_title: comment.threadTitle,
    },
  });
  return runId;
}
```

```python
# Start the journey on every new comment, once per person who should hear
# about it, with the journey ID setup.ts printed. The first comment opens a
# batch for that person; the rest join it and end there. The email reads the
# person's first name from their Courier profile.
from courier import Courier

client = Courier()


def notify_comment(journey_id: str, recipient_id: str, comment: dict) -> str:
    response = client.journeys.invoke(
        template_id=journey_id,
        user_id=recipient_id,
        data={
            "author_name": comment["author_name"],
            "comment_text": comment["text"],
            "thread_title": comment["thread_title"],
        },
    )
    return response.run_id
```

## Stop it when they read the comments

```typescript
// Drop the pending notification when the person reads the comments in your
// app before the batch goes out. It cancels the run holding their batch.
import Courier from "@trycourier/courier";

const client = new Courier();

export async function dropPendingComments(userId: string) {
  await client.journeys.cancel({ cancelation_token: `comments-${userId}` });
}
```

```python
# Drop the pending notification when the person reads the comments in your
# app before the batch goes out. It cancels the run holding their batch.
from courier import Courier

client = Courier()


def drop_pending_comments(user_id: str) -> None:
    client.journeys.cancel(cancelation_token=f"comments-{user_id}")
```

## Templates

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

templates/comment-batch-inbox.json

```json
{
  "channel": "inbox",
  "notification": {
    "name": "New comments (Inbox)",
    "tags": ["recipe", "comment-notification-batching"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "inbox",
          "elements": [
            {
              "type": "meta",
              "title": "New comments ({{batch.count}})"
            },
            {
              "type": "text",
              "content": "{{$.item.author_name}} on {{$.item.thread_title}}: \"{{$.item.comment_text}}\"",
              "loop": "data.batch.items"
            },
            {
              "type": "action",
              "content": "See all comments",
              "href": "https://app.acme.com/comments"
            }
          ]
        }
      ]
    }
  }
}
```

templates/comment-batch-email.json

```json
{
  "channel": "email",
  "notification": {
    "name": "New comments (email)",
    "tags": ["recipe", "comment-notification-batching"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "New comments ({{batch.count}})"
            },
            {
              "type": "text",
              "content": "Hi {{default profile.first_name \"there\"}}, here are the latest comments on threads you follow:"
            },
            {
              "type": "text",
              "content": "{{$.item.author_name}} on {{$.item.thread_title}}: \"{{$.item.comment_text}}\"",
              "loop": "data.batch.items"
            },
            {
              "type": "action",
              "content": "See all comments",
              "href": "https://app.acme.com/comments"
            }
          ]
        }
      ]
    }
  }
}
```

## Hand it to your agent

```text
Batch comment notifications in this app, sent through Courier: when comments arrive in a burst, send each person one in-app and email notification with the count and the latest comments, not one per comment. Follow the steps at https://www.courier.com/recipes/comment-notification-batching.md and use the Courier skill for SDK details.

1. Create the journey and its templates in my Courier workspace by replacing the comments link in the template files with our own, then running setup.ts from that page with COURIER_API_KEY from our environment. Save the journey ID it prints as COURIER_COMMENTS_JOURNEY_ID.
2. Find where this codebase saves a new comment and start the journey there, once for each person who should hear about it (everyone following the thread except the author). Pass the fields in the Data fields table from our own data, and make sure first_name is written to each person's Courier profile at signup.
3. Find where a user opens the comments and cancel the journey with the token comments-<user_id>.
4. Match this codebase's language, file layout, and error handling. Never hardcode the API key.
5. When you're done, add three comments for a test user a few seconds apart, and once the batch releases 10 minutes later, show me the steps of the run that sent.
```

## Data fields

| Field | Type | Example | Note |
| --- | --- | --- | --- |
| author_name | string | Sam |  |
| comment_text | string | Can we ship Friday? |  |
| thread_title | string | Q4 launch plan |  |
| profile.first_name | string | Dana | From the profile. Write it when the user signs up. The emails say "there" when it's missing |
| batch.count | number | 3 | Set by the batch node, not your data |

## FAQ

### How do I batch comment notifications?

Start a journey on every comment and put a batch node before the sends. The first comment opens a batch, later ones join it, and one notification goes out when the comments stop coming.

### How long should the batch window be?

Long enough to catch a back-and-forth, short enough that replies still feel live. This recipe waits for 10 quiet minutes, never more than an hour, and releases early at 100 comments. The shortest quiet window the API accepts is 1 minute.

### Does a single comment still wait?

Yes. A lone comment waits out the quiet window too, then goes out as a batch of one.

### How do I skip the notification if they've already read the comments?

Cancel the token comments-<user_id> when they open the comments in your app. That drops the pending batch before it sends.

## Related docs

- [Batch node](https://www.courier.com/docs/journeys/nodes/batch)
- [Notify everyone watching](https://www.courier.com/docs/guides/notify-everyone-watching)
- [Send to the Inbox](https://www.courier.com/docs/in-app/send-to-the-inbox)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)

## Related recipes

- [Weekly activity digest](https://www.courier.com/recipes/weekly-digest-email): Send one weekly summary instead of a dozen separate pings.
- [Team invite with reminder](https://www.courier.com/recipes/team-invite-reminder-email): Invite a teammate, remind them if they don't join, and tell the inviter if they still haven't.
- [Feature adoption emails](https://www.courier.com/recipes/feature-adoption-email-sequence): Nudge new users who haven't tried your key feature, and compare two versions of the first email.
