# Replace localhost with the generated domain, then copy it
https://my-localtunnel-url.loca.lt/api/liveblocks-notifications
```

You now have a URL that can be used in the webhooks dashboard.

## Set up webhooks on the Liveblocks dashboard

To use webhooks, you need to pass your endpoint URL to the webhooks dashboard
inside your Liveblocks project, and tell the webhook to trigger when a comment
has been created.

<Steps>
  <Step>
    <StepTitle>Select your project</StepTitle>
    <StepContent>
      From the [Liveblocks dashboard](/dashboard), navigate to the project you’d
      like to use with webhooks, or create a new project.
      <Figure>
        <Image
          src="/assets/tutorials/webhooks/liveblocks-create-project.png"
          alt="Create a Liveblocks project"
          width={712}
          height={445}
        />
      </Figure>
    </StepContent>
  </Step>

<Step>
  <StepTitle>Go to the notifications dashboard</StepTitle>
  <StepContent>
    Click on the **“Notifications”** tab on the menu at the left.
    <Figure>
      <Image
        src="/assets/tutorials/webhooks/liveblocks-click-notifications.png"
        alt="Click notifications"
        width={712}
        height={445}
      />
    </Figure>
  </StepContent>
</Step>

<Step>
  <StepTitle>Enable the thread notification type</StepTitle>
  <StepContent>
    Click on **“Edit”** at the top right, enable `thread` notifications on the
    email channel, and publish your changes.
    <Figure>
      <Image
        src="/assets/tutorials/webhooks/liveblocks-enable-thread-notification.png"
        alt="Enable thread notifications"
        width={712}
        height={445}
      />
    </Figure>
  </StepContent>
</Step>

<Step>
  <StepTitle>Go to the webhooks dashboard</StepTitle>
  <StepContent>
    Click on the **“Webhooks”** tab on the menu at the left.
    <Figure>
      <Image
        src="/assets/tutorials/webhooks/liveblocks-click-webhooks.png"
        alt="Click webhooks"
        width={712}
        height={445}
      />
    </Figure>
  </StepContent>
</Step>

<Step>
  <StepTitle>Create an endpoint</StepTitle>
  <StepContent>
    Click the **“Create endpoint…”** button on the webhooks dashboard to start
    setting up your webhook.
    <Figure>
      <Image
        src="/assets/tutorials/webhooks/liveblocks-add-endpoint.png"
        alt="Click add endpoint"
        width={712}
        height={445}
      />
    </Figure>
  </StepContent>
</Step>

<Step>
  <StepTitle>Add your endpoint URL</StepTitle>
  <StepContent>
    Enter the URL of the endpoint. In a production app this will be the real
    endpoint, but for now enter your `localtunnel` URL from earlier.
    <Figure>
      <Image
        src="/assets/tutorials/webhooks/liveblocks-add-endpoint-url.png"
        alt="Add endpoint URL"
        width={712}
        height={445}
      />
    </Figure>
  </StepContent>
</Step>

<Step>
  <StepTitle>Get your webhook secret key</StepTitle>
  <StepContent>
    Click **“Create endpoint”** at the bottom, then find your **“Secret key”**
    on the next page, and copy it.
    <Figure>
      <Image
        src="/assets/tutorials/webhooks/liveblocks-get-secret-key.png"
        alt="Copy your webhook secret key"
        width={710}
        height={445}
      />
    </Figure>
  </StepContent>
</Step>

  <Step lastStep>
    <StepTitle>Webhooks dashboard is set up!</StepTitle>

    <StepContent>
      Note that you can filter specifically for `notification` events, but we’re
      ignoring this for now so we can test more easily. Let’s go back to the
      code.
    </StepContent>

  </Step>
</Steps>

## Verify the webhook request

The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides
you with a function that verifies whether the current request is a real webhook
request from Liveblocks. You can set this up by setting up a
[`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and
running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest).

Make sure to add your “Webhook secret key” from the Liveblocks dashboard—in a
real project we’d recommend using an environment variable for this.

```ts highlight="1,3-5,11-21"
import { WebhookHandler } from "@liveblocks/node";

// Add your webhook secret key from a project's webhooks dashboard
const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY";
const webhookHandler = new WebhookHandler(WEBHOOK_SECRET);

export async function POST(request: Request) {
  const body = await request.json();
  const headers = request.headers;

  // Verify if this is a real webhook request
  let event;
  try {
    event = webhookHandler.verifyRequest({
      headers: headers,
      rawBody: JSON.stringify(body),
    });
  } catch (err) {
    console.error(err);
    return new Response("Could not verify webhook call", { status: 400 });
  }

  // Send notifications
  // ...

  return new Response(null, { status: 200 });
}
```

## Check the event and notification permissions

After verifying the request, we can then check we’re receiving the correct type
of event, on the correct channel. There are different `notification` events, and
in this case we’d like to check for
[thread notification](/docs/platform/webhooks#Thread-notification), as we’re
specifically listening for new comments. We can do this using
[`ThreadNotificationEvent`](/docs/api-reference/liveblocks-node#isThreadNotificationEvent),
making sure to check for the `email` channel.

```ts
import { WebhookHandler, isThreadNotificationEvent } from "@liveblocks/node";

// Add your webhook secret key from a project's webhooks dashboard
const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY";
const webhookHandler = new WebhookHandler(WEBHOOK_SECRET);

export async function POST(request: Request) {
  const body = await request.json();
  const headers = request.headers;

  // Verify if this is a real webhook request
  let event;
  try {
    event = webhookHandler.verifyRequest({
      headers: headers,
      rawBody: JSON.stringify(body),
    });
  } catch (err) {
    console.error(err);
    return new Response("Could not verify webhook call", { status: 400 });
  }

  // +++
  // When an inbox notification has been created on the email channel
  if (isThreadNotificationEvent(event) && event.data.channel === "email") {
    // Check if user has access to room
    if (!__hasRoomAccess__(event.userId, event.roomId)) {
      return new Response(null, { status: 200 });
    }

    // The user to send the email to
    const emailAddress = __getEmailAddressFromDB__(event.userId);

    // Send notifications
    // ...
  }
  // +++

  return new Response(null, { status: 200 });
}
```

Note that we’re also checking if the user should receive a notification, and
getting their email address—Liveblocks doesn’t have knowledge of your
permissions system on the back end, so it’s your responsibility to check if this
user should have access to the room.

## Fetching data for emails

[`@liveblocks/emails`](/docs/api-reference/liveblocks-emails) provides functions
for fetching unread comments and styling emails, returning them as either React
components or an HTML string. In this guide we’ll use the React function, but
the HTML function works almost identically, so you can still follow along if
you’d prefer HTML emails.

<Figure>
  <Image
    src="/assets/emails/new-comments-email.png"
    alt="An email showing 7 new comments, with comment bodies and links to each comment"
    width={1567}
    height={830}
  />
</Figure>

First set up your
[`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js
client and wrap
[`prepareThreadNotificationEmailAsReact`](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react)
in `try/catch`, getting the data for the email.

<Banner>

If you’d prefer your email content as an HTML string, use
[`prepareThreadNotificationEmailAsHtml`](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-html).

</Banner>

```ts
import {
  // +++
  Liveblocks,
  // +++
  WebhookHandler,
  isThreadNotificationEvent,
} from "@liveblocks/node";
// +++
import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails";
// +++

// Add your webhook secret key from a project's webhooks dashboard
const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY";
const webhookHandler = new WebhookHandler(WEBHOOK_SECRET);

// +++
// Add your secret key from a project's API keys dashboard
const API_SECRET = "{{SECRET_KEY}}";
const liveblocks = new Liveblocks({ secret: API_SECRET });
// +++

export async function POST(request: Request) {
  const body = await request.json();
  const headers = request.headers;

  // Verify if this is a real webhook request
  let event;
  try {
    event = webhookHandler.verifyRequest({
      headers: headers,
      rawBody: JSON.stringify(body),
    });
  } catch (err) {
    console.error(err);
    return new Response("Could not verify webhook call", { status: 400 });
  }

  // When an inbox notification has been created on the email channel
  if (isThreadNotificationEvent(event) && event.data.channel === "email") {
    // Check if user has access to room
    if (!__hasRoomAccess__(event.userId, event.roomId)) {
      return new Response(null, { status: 200 });
    }

    // The user to send the email to
    const emailAddress = __getEmailAddressFromDB__(event.userId);

    // +++
    let emailData;

    try {
      emailData = await prepareThreadNotificationEmailAsReact(
        liveblocks,
        event
      );
    } catch (err) {
      console.log(err);
      return new Response("Could not fetch thread notification data", {
        status: 500,
      });
    }

    // All comments have already been read
    if (!emailData) {
      return new Response(null, { status: 200 });
    }

    // Create emails
    // ...
    // +++
  }

  return new Response(null, { status: 200 });
}
```

## Create the emails

Next, we need to create the emails with React.
[`prepareThreadNotificationEmailAsReact`](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react)
helps you identify two different thread notification types, _unread replies_ in
a thread, or an _unread mention_ in a comment. We can choose to create different
emails for these cases.

```tsx
import {
  Liveblocks,
  WebhookHandler,
  isThreadNotificationEvent,
} from "@liveblocks/node";
import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails";

// Add your webhook secret key from a project's webhooks dashboard
const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY";
const webhookHandler = new WebhookHandler(WEBHOOK_SECRET);

// Add your secret key from a project's API keys dashboard
const API_SECRET = "{{SECRET_KEY}}";
const liveblocks = new Liveblocks({ secret: API_SECRET });

export async function POST(request: Request) {
  const body = await request.json();
  const headers = request.headers;

  // Verify if this is a real webhook request
  let event;
  try {
    event = webhookHandler.verifyRequest({
      headers: headers,
      rawBody: JSON.stringify(body),
    });
  } catch (err) {
    console.error(err);
    return new Response("Could not verify webhook call", { status: 400 });
  }

  // When an inbox notification has been created on the email channel
  if (isThreadNotificationEvent(event) && event.data.channel === "email") {
    // Check if user has access to room
    if (!__hasRoomAccess__(event.userId, event.roomId)) {
      return new Response(null, { status: 200 });
    }

    // The user to send the email to
    const emailAddress = __getEmailAddressFromDB__(event.userId);

    let emailData;

    try {
      emailData = await prepareThreadNotificationEmailAsReact(
        liveblocks,
        event
      );
    } catch (err) {
      console.log(err);
      return new Response("Could not fetch thread notification data", {
        status: 500,
      });
    }

    // All comments have already been read
    if (!emailData) {
      return new Response(null, { status: 200 });
    }

    // +++
    let email;
    switch (emailData.type) {
      case "unreadMention": {
        email = (
          <div>
            <div>
              @{emailData.comment.author.id} at {emailData.comment.createdAt}
            </div>
            <div>{emailData.comment.body}</div>
          </div>
        );
        break;
      }

      case "unreadReplies": {
        email = (
          <div>
            {emailData.comments.map((comment) => (
              <div key={comment.id}>
                <div>
                  @{comment.author.id} at {comment.createdAt}
                </div>
                <div>{comment.body}</div>
              </div>
            ))}
          </div>
        );
        break;
      }
    }
    // +++

    // Send emails
    // ...
  }

  return new Response(null, { status: 200 });
}
```

## Resolving data

We’ve now fully created a basic React email, and it’s ready to send. However,
we’re displaying each user’s ID, and not their names. We can go back to
[`prepareThreadNotificationEmailAsReact`](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react)
and use resolver functions to transform an ID into a name, for example
`steven@example.com` -> `Steven`. These functions work similarly to
[resolvers on the client](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers).

```tsx
// ...

emailData = await prepareThreadNotificationEmailAsReact(liveblocks, event, {
  // +++
  resolveUsers: async ({ userIds }) => {
    const usersData = await __getUsersFromDB__(userIds);

    return usersData.map((userData) => ({
      name: userData.name, // "Steven"
      avatar: userData.avatar.src, // "https://example.com/steven.jpg"
    }));
  },
  resolveRoomInfo: async ({ roomId }) => {
    const roomData = await __getRoomFromDB__(roomId);

    return {
      name: roomData.name, // "Untitled document"
      url: roomData.url, //`https://example.com/my-room-id`
    };
  },
  // +++
});

// ...
```

## Customizing comment components

We can also edit `prepareThreadNotificationEmailAsReact` to allow for
[custom components in comment bodies](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react-customizing-components),
for example we can add margin around a paragraph, color mentions, and underline
links.

```tsx
// ...

emailData = await prepareThreadNotificationEmailAsReact(liveblocks, event, {
  resolveUsers: async ({ userIds }) => {
    const usersData = await __getUsersFromDB__(userIds);

    return usersData.map((userData) => ({
      name: userData.name, // "Steven"
      avatar: userData.avatar.src, // "https://example.com/steven.jpg"
    }));
  },
  resolveRoomInfo: async ({ roomId }) => {
    const roomData = await __getRoomFromDB__(roomId);

    return {
      name: roomData.name, // "Untitled document"
      url: roomData.url, //`https://example.com/my-room-id`
    };
  },
  // +++
  components: {
    Paragraph: ({ children }) => <p style={{ margin: "12px 0" }}>{children}</p>,

    // `user` is the optional data returned from `resolveUsers`
    Mention: ({ element, user }) => (
      <span style={{ color: "red" }}>@{user?.name ?? element.id}</span>
    ),

    // If the link is rich-text render it, otherwise use the URL
    Link: ({ element, href }) => (
      <a href={href} style={{ textDecoration: "underline" }}>
        {element?.text ?? href}
      </a>
    ),
  },
  // +++
});

// ...
```

Any component can be passed here, including those used in
[`react-email`](https://react.email/),
[learn more](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react-customizing-components).

<Banner>

If you’re using HTML instead of React, you can
[apply custom CSS properties](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-html-styling-elements).

</Banner>

## Send notification emails

Now that the React code has been generated, we can send the notification emails.
[Resend](https://resend.com) is a great tool for easily sending emails, and in
this code example, we’re using it to send notifications to each user. Make sure
to add your API key from the [Resend dashboard](https://resend.com/overview)
before running the code.

```tsx
import {
  Liveblocks,
  WebhookHandler,
  isThreadNotificationEvent,
} from "@liveblocks/node";
import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails";
// +++
import { Resend } from "resend";
// +++

// +++
// Create Resend client (add your API key)
const resend = new Resend("re_123456789");
// +++

// Add your webhook secret key from a project's webhooks dashboard
const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY";
const webhookHandler = new WebhookHandler(WEBHOOK_SECRET);

// Add your secret key from a project's API keys dashboard
const API_SECRET = "{{SECRET_KEY}}";
const liveblocks = new Liveblocks({ secret: API_SECRET });

export async function POST(request: Request) {
  const body = await request.json();
  const headers = request.headers;

  // Verify if this is a real webhook request
  let event;
  try {
    event = webhookHandler.verifyRequest({
      headers: headers,
      rawBody: JSON.stringify(body),
    });
  } catch (err) {
    console.error(err);
    return new Response("Could not verify webhook call", { status: 400 });
  }

  // When an inbox notification has been created on the email channel
  if (isThreadNotificationEvent(event) && event.data.channel === "email") {
    // Check if user has access to room
    if (!__hasRoomAccess__(event.userId, event.roomId)) {
      return new Response(null, { status: 200 });
    }

    // The user to send the email to
    const emailAddress = __getEmailAddressFromDB__(event.userId);

    let emailData;

    try {
      emailData = await prepareThreadNotificationEmailAsReact(
        liveblocks,
        event,
        {
          resolveUsers: async ({ userIds }) => {
            const usersData = await __getUsersFromDB__(userIds);

            return usersData.map((userData) => ({
              name: userData.name, // "Steven"
              avatar: userData.avatar.src, // "https://example.com/steven.jpg"
            }));
          },
          resolveRoomInfo: async ({ roomId }) => {
            const roomData = await __getRoomFromDB__(roomId);

            return {
              name: roomData.name, // "Untitled document"
              url: roomData.url, //`https://example.com/my-room-id`
            };
          },
          components: {
            Paragraph: ({ children }) => (
              <p style={{ margin: "12px 0" }}>{children}</p>
            ),

            // `user` is the optional data returned from `resolveUsers`
            Mention: ({ element, user }) => (
              <span style={{ color: "red" }}>@{user?.name ?? element.id}</span>
            ),

            // If the link is rich-text render it, otherwise use the URL
            Link: ({ element, href }) => (
              <a href={href} style={{ textDecoration: "underline" }}>
                {element?.text ?? href}
              </a>
            ),
          },
        }
      );
    } catch (err) {
      console.log(err);
      return new Response("Could not fetch thread notification data", {
        status: 500,
      });
    }

    // All comments have already been read
    if (!emailData) {
      return new Response(null, { status: 200 });
    }

    let email;
    switch (emailData.type) {
      case "unreadMention": {
        email = (
          <div>
            <div>
              @{emailData.comment.author.id} at {emailData.comment.createdAt}
            </div>
            <div>{emailData.comment.body}</div>
          </div>
        );
        break;
      }

      case "unreadReplies": {
        email = (
          <div>
            {emailData.comments.map((comment) => (
              <div key={comment.id}>
                <div>
                  @{comment.author.id} at {comment.createdAt}
                </div>
                <div>{comment.body}</div>
              </div>
            ))}
          </div>
        );
        break;
      }
    }

    // +++
    // Send email to the user's email address
    try {
      const data = await resend.emails.send({
        from: "My company <hello@my-company.com>",
        to: emailAddress,
        subject: "New comment",
        react: email,
      });
    } catch (err) {
      console.error(err);
    }
    // +++
  }

  return new Response(null, { status: 200 });
}
```

## Allow users to toggle notifications

Using Liveblocks hooks and methods, it’s possible to create a notifications
settings interface, allowing end users to choose which notifications they’d like
to receive, and on which channels, saving their preferences.

<Figure>
  <Image
    src="/assets/notifications/notification-settings.jpg"
    alt="Notification settings"
    width={1344}
    height={896}
  />
</Figure>

Learn more in our guide on
[creating a notification settings panel](/docs/guides/how-to-create-a-notification-settings-panel).

## Recap

Great, we’re successfully sending email notifications after new comments are
created! In this guide we’ve learned:

- How to use [webhooks](/docs/platform/webhooks) and the
  [`NotificationEvent`](/docs/platform/webhooks#NotificationEvent).
- How to use the [`@liveblocks/emails`](/docs/api-reference/liveblocks-emails)
  package to fetch and render unread thread data.
- How to send email notifications with [Resend](https://resend.com).

---
meta:
  title: "How to send email notifications when comments are created"
  description:
    "Learn how to automatically send email notifications when a comment or
    thread is created with Liveblocks Comments."
---

Liveblocks allows you to build a commenting experience with
[Comments](/docs/ready-made-features/comments). Using our webhooks and REST API,
it’s possible to send email notifications to users when they’re mentioned in
comments.

<Banner title="You’re probably looking for a different guide" type="warning">

This guide is about sending an email immediately after every comment is posted.
If you’d prefer aggregate multiple notifications into one email, and only notify
users about unread comments, you should read this guide on
[how to send email notifications of unread comments](/docs/guides/how-to-send-email-notifications-of-unread-comments).

</Banner>

## What we’re building

In this guide we’ll be learning how to send comments notifications, and more
specifically, we’ll be looking at how to:

- Trigger events when comments are created using the
  [CommentCreated](/docs/platform/webhooks#CommentCreatedEvent) webhook event.
- Fetch a comment’s data using the
  [@liveblocks/node](/docs/api-reference/liveblocks-node) package.
- Create notifications containing the comment’s
  [formatted text](/docs/guides/how-to-send-email-notifications-when-comments-are-created#Formatting-a-comment's-body).
- Send an email notification with [Resend](https://resend.com/).

<Banner title="Have a project ready">

This guide assumes you already have a Liveblocks Comments project set up. If you
haven’t already got one, you can
[get started with Comments](/docs/get-started/comments), and come back after
you’re set up. You could also use our
[basic Comments example](/examples/comments/nextjs-comments).

</Banner>

## Create an endpoint in your project

When a webhook event is triggered, it can send a POST request to the back end in
your project, and from within there we can send the email. In this guide, we’ll
be using a Next.js route handler (API endpoint) as an example, but other
frameworks work similarly.

In order to use webhooks, we’ll need to retrieve the `headers` and `body` from
the request. Here’s the basic endpoint we’ll be starting from:

```ts
export async function POST(request: Request) {
  const body = await request.json();
  const headers = request.headers;

  // Handle webhooks and notifications
  // ...

  return new Response(null, { status: 200 });
}
```

Create this endpoint in your project, and make it available on `localhost`, for
example at the following URL:

```
/api/liveblocks-notifications
```

Make a note of this endpoint URL, as you’ll be using it later.

### Testing webhooks locally

Running webhooks locally can be difficult, but one way to do this is to use a
tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or
[`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily
put your localhost server online.

If your project is running on `localhost:3000`, you can run the following
command to generate a temporary URL that’s available while your localhost server
is running:

```bash
npx localtunnel --port 3000
```

`localtunnel` generates a base URL that can be placed into the Liveblocks
webhooks dashboard for quick testing. To use this, take the full address of your
webhook endpoint, and replace the domain in your `localhost` address with the
generated URL.

```shell