Slack notifications

An optional Slack message about each new inquiry, sent after the response and free of personal data.

When you set the Worker secret SLACK_WEBHOOK_URL to a Slack incoming webhook URL, goodnight-web sends a Slack message whenever a new inquiry arrives. The message is sent after the visitor gets a response. It names your site and the page the form was on, and it contains no personal data and no inquiry contents.

What the message looks like

For an inquiry sent from /contact on example.com, with the language set to English, the message has these parts from top to bottom:

PartWhat it shows
Heading🌙 New inquiry
TextA new inquiry arrived on example.com.
ReceivedThe date and time, such as 2026-10-23 22:35, in the reader's time zone
Page/contact
NoteThis notification does not include personal data or anything typed into the form.
ButtonOpen in admin. It opens the inquiry in the admin screen, which asks for a passkey sign-in as usual

In Japanese, the heading is "🌙 新しいお問い合わせ". Slack's notification banner and screen readers use the text line without bold instead: "A new inquiry arrived on example.com."

What it contains

  • The site name. By default this is the hostname of the request, such as example.com. Set the siteName option to show another name.
  • The time the inquiry was received. It is sent as a Slack date token, so Slack shows it in each reader's own time zone. The date is shown as 2026-10-23 in every language. The time follows each reader's Slack setting, so it appears as 22:35 or 10:35 PM. A Slack app that cannot show the token shows the time in UTC, such as 2026-10-23 13:35 UTC.
  • The page the form was on. Only the path, such as /contact. A query string or a # part is removed. When the page path is not known, this part is left out.
  • A button to the inquiry in the admin screen.

The message contains no email address, no name, no form field values, not even the option chosen in a dropdown field, and no IP address or user agent. This does not change when admins add fields to the form.

Language and site name

Both are options of createGoodnightWorker() in your site's src/worker.ts:

src/worker.ts
export default createGoodnightWorker({
  locale: siteConfig.defaultLocale,
  siteName: 'My Company',
});
  • locale sets the language of the message: ja or en. Without it, the message is in English. The starter passes the site's default language.
  • siteName replaces the hostname in the message. A blank value counts as not set. Slack shows the name as plain text: *, _, ~, and backticks appear as their full-width forms, so they cannot turn on bold or other formatting, and < and > cannot make links or mentions. A name longer than 80 characters is cut and ends with ….

Other rules

  • Only Slack is called. After the Worker saves an inquiry that is not suspected spam, it posts to the webhook. The URL must have the form https://hooks.slack.com/services/T…/B…/…, with nothing after the last segment, not even a slash. Any other value is ignored, and the inquiry list in the admin screen shows a warning that the URL is not in the expected format. The warning does not show the URL.
  • Success is recorded; there is no retry. When the post succeeds, the Worker records notified_at. A failed post is not sent again.
  • Suspected spam does not trigger a notification. That is a submission with a filled honeypot or a spam score of 3 or more. It is saved and listed under Suspected spam in the admin screen. See Contact form protection.

In the code

After the row is saved, the contact API starts the notification only for an inquiry that is not suspected spam:

packages/server/src/features/contact/contact-routes.ts
// 8. After the response: notification (never for suspected spam) and
//    pruning.
const webhookUrl = env.SLACK_WEBHOOK_URL;
if (!suspectedSpam && isSlackWebhookUrl(webhookUrl)) {
  runInBackground(c, 'slack', () =>
    sendSlackNotification({
      db: env.DB,
      webhookUrl,
      submissionId: id,
      payload: buildSlackPayload({
        locale: options.locale,
        receivedAt: now,
        adminUrl: `${origin}/admin/inquiries/${id}`,
        siteName: options.siteName ?? new URL(c.req.url).hostname,
        pagePath: data.pagePath,
      }),
    }),
  );
}

The message is built from these values only. The function has no parameter for anything the visitor typed:

packages/server/src/features/notify/slack.ts
export const buildSlackPayload = ({
  locale,
  receivedAt,
  adminUrl,
  siteName,
  pagePath,
}: {
  locale: Locale;
  receivedAt: number;
  adminUrl: string;
  siteName: string;
  pagePath?: string | null;
}): SlackPayload => {

Why it is built this way

  • The response does not wait for Slack. The post runs in ctx.waitUntil after the inquiry is saved and the visitor has the response. A Slack outage cannot lose an inquiry or show the visitor an error.
  • No personal data leaves Cloudflare. Slack is a separate service. The message carries only the site name, the page path, the time, and a link, so inquiry contents stay in the D1 database.
  • No form answers are sent. Admins decide what the form asks, so the server cannot know which answers are safe to send.
  • The site name tells sites apart. When several sites post to one channel, the name shows which site the inquiry came to.
  • No Cloudflare Queues and no retry. The admin screen always shows the unread count in its header, so a missed notification does not mean a missed inquiry.
  • The webhook URL is a secret. Anyone with the URL can post to your channel, so it is kept as a Worker secret and never shown in the admin screen.

On this page